rufio 1.1.0 → 1.4.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 +50 -0
- data/README.md +26 -297
- data/README_ja.md +24 -261
- data/Rakefile +14 -1
- data/bin/rufio +31 -2
- data/docs/fast_zip_design_ja.md +167 -0
- data/docs/guide.md +410 -0
- data/docs/guide_ja.md +410 -0
- data/info/help.md +54 -17
- data/info/keybindings.md +50 -22
- data/info/welcome.md +14 -5
- 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 +28 -2
- data/lib/rufio/command_history.rb +5 -2
- data/lib/rufio/command_mode.rb +60 -134
- data/lib/rufio/command_mode_ui.rb +4 -42
- data/lib/rufio/config.rb +88 -136
- data/lib/rufio/config_loader.rb +4 -11
- data/lib/rufio/dialog_renderer.rb +90 -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_operations.rb +28 -6
- data/lib/rufio/file_preview.rb +46 -2
- data/lib/rufio/global_search.rb +125 -13
- data/lib/rufio/help_menu.rb +19 -7
- 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 +83 -116
- 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 +20 -26
- data/lib/rufio/path_jump.rb +55 -0
- 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 +84 -298
- data/lib/rufio/text_utils.rb +26 -3
- data/lib/rufio/ui_renderer.rb +73 -45
- 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 +6 -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 +128 -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]
|
|
@@ -142,10 +147,14 @@ module Rufio
|
|
|
142
147
|
left_width = (@screen_width * @left_panel_ratio).to_i
|
|
143
148
|
right_width = @screen_width - left_width
|
|
144
149
|
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
if @keybind_handler.preview_focused?
|
|
151
|
+
draw_file_preview_to_buffer(screen, selected_entry, @screen_width, content_height, 0)
|
|
152
|
+
else
|
|
153
|
+
draw_directory_list_to_buffer(screen, entries, left_width, content_height)
|
|
154
|
+
draw_file_preview_to_buffer(screen, selected_entry, right_width, content_height, left_width)
|
|
155
|
+
end
|
|
147
156
|
|
|
148
|
-
draw_mode_tabs_to_buffer(screen, @screen_height - 1)
|
|
157
|
+
draw_mode_tabs_to_buffer(screen, @screen_height - 1, show_path: false)
|
|
149
158
|
end
|
|
150
159
|
|
|
151
160
|
# 通知メッセージがある場合は表示
|
|
@@ -223,7 +232,7 @@ module Rufio
|
|
|
223
232
|
# FilePreview の結果ハッシュからプレーンテキスト行を抽出する
|
|
224
233
|
def extract_preview_lines(preview)
|
|
225
234
|
case preview[:type]
|
|
226
|
-
when 'text', 'code'
|
|
235
|
+
when 'text', 'code', 'archive'
|
|
227
236
|
preview[:lines]
|
|
228
237
|
when 'binary'
|
|
229
238
|
["(#{ConfigLoader.message('file.binary_file')})", ConfigLoader.message('file.cannot_preview')]
|
|
@@ -309,9 +318,10 @@ module Rufio
|
|
|
309
318
|
|
|
310
319
|
def draw_file_preview_to_buffer(screen, selected_entry, width, height, left_offset)
|
|
311
320
|
# 事前計算
|
|
321
|
+
fullscreen = @keybind_handler&.preview_focused?
|
|
312
322
|
cursor_position = left_offset + CURSOR_OFFSET
|
|
313
323
|
max_chars_from_cursor = @screen_width - cursor_position
|
|
314
|
-
safe_width = [max_chars_from_cursor - 2, width - 2, 0].max
|
|
324
|
+
safe_width = fullscreen ? @screen_width : [max_chars_from_cursor - 2, width - 2, 0].max
|
|
315
325
|
|
|
316
326
|
# プレビューコンテンツをキャッシュから取得
|
|
317
327
|
preview_content = nil
|
|
@@ -382,13 +392,13 @@ module Rufio
|
|
|
382
392
|
end
|
|
383
393
|
end
|
|
384
394
|
|
|
385
|
-
content_x = cursor_position + 1
|
|
395
|
+
content_x = fullscreen ? 0 : cursor_position + 1
|
|
386
396
|
|
|
387
397
|
(0...height).each do |i|
|
|
388
398
|
line_num = i + CONTENT_START_LINE
|
|
389
399
|
|
|
390
400
|
# 区切り線
|
|
391
|
-
screen.put(cursor_position, line_num, '│')
|
|
401
|
+
screen.put(cursor_position, line_num, '│') unless fullscreen
|
|
392
402
|
|
|
393
403
|
next if safe_width <= 0
|
|
394
404
|
|
|
@@ -473,7 +483,7 @@ module Rufio
|
|
|
473
483
|
|
|
474
484
|
def draw_footer_to_buffer(screen, y, fps = nil)
|
|
475
485
|
if @keybind_handler.filter_active?
|
|
476
|
-
if @keybind_handler.
|
|
486
|
+
if @keybind_handler.filter_mode?
|
|
477
487
|
help_text = "Filter mode: Type to filter, ESC to clear, Enter to apply, Backspace to delete"
|
|
478
488
|
else
|
|
479
489
|
help_text = "Filtered view active - Space to edit filter, ESC to clear filter"
|
|
@@ -518,13 +528,8 @@ module Rufio
|
|
|
518
528
|
if @background_executor.running?
|
|
519
529
|
command_name = @background_executor.current_command || "処理中"
|
|
520
530
|
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
|
|
531
|
+
elsif @background_status.message
|
|
532
|
+
right_parts << @background_status.message
|
|
528
533
|
end
|
|
529
534
|
end
|
|
530
535
|
|
|
@@ -575,7 +580,41 @@ module Rufio
|
|
|
575
580
|
# モードタブ描画
|
|
576
581
|
# ============================
|
|
577
582
|
|
|
578
|
-
|
|
583
|
+
# 現在のパスを 1 行で表示する(ホームは ~、長い場合は左側を省略)
|
|
584
|
+
def draw_path_line_to_buffer(screen, y)
|
|
585
|
+
path_text = " #{format_header_path(@directory_listing.current_path, @screen_width - 2)}"
|
|
586
|
+
screen.put_string(0, y, path_text.ljust(@screen_width), fg: "\e[1m")
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def format_header_path(path, max_width)
|
|
590
|
+
home = Dir.home rescue nil
|
|
591
|
+
display = path.to_s
|
|
592
|
+
if home && !home.empty?
|
|
593
|
+
if display == home
|
|
594
|
+
display = '~'
|
|
595
|
+
elsif display.start_with?(home + '/')
|
|
596
|
+
display = "~#{display.delete_prefix(home)}"
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
return display if TextUtils.display_width(display) <= max_width
|
|
600
|
+
|
|
601
|
+
# 末尾(現在のディレクトリ側)を残して先頭を … に置き換える
|
|
602
|
+
ellipsis = '…'
|
|
603
|
+
budget = max_width - TextUtils.display_width(ellipsis)
|
|
604
|
+
tail = +''
|
|
605
|
+
tail_width = 0
|
|
606
|
+
display.reverse.each_char do |char|
|
|
607
|
+
char_width = TextUtils.display_width(char)
|
|
608
|
+
break if tail_width + char_width > budget
|
|
609
|
+
|
|
610
|
+
tail.prepend(char)
|
|
611
|
+
tail_width += char_width
|
|
612
|
+
end
|
|
613
|
+
"#{ellipsis}#{tail}"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# show_path: Files モードではパスを最上段に出すため、タブ行には表示しない
|
|
617
|
+
def draw_mode_tabs_to_buffer(screen, y, show_path: true)
|
|
579
618
|
# タブモードマネージャの状態を同期
|
|
580
619
|
sync_tab_mode_with_keybind_handler
|
|
581
620
|
|
|
@@ -626,7 +665,7 @@ module Rufio
|
|
|
626
665
|
current_x += 1
|
|
627
666
|
|
|
628
667
|
path_end = @screen_width - 1 - version_w
|
|
629
|
-
path_str = " #{shorten_path(current_path)} "
|
|
668
|
+
path_str = show_path ? " #{shorten_path(current_path)} " : ''
|
|
630
669
|
path_str.each_char do |char|
|
|
631
670
|
break if current_x >= path_end
|
|
632
671
|
char_w = TextUtils.display_width(char)
|
|
@@ -674,7 +713,7 @@ module Rufio
|
|
|
674
713
|
selected_index = job_mode_instance&.selected_index || 0
|
|
675
714
|
|
|
676
715
|
(0...height).each do |i|
|
|
677
|
-
line_num = i +
|
|
716
|
+
line_num = i + JOB_CONTENT_START_LINE
|
|
678
717
|
|
|
679
718
|
if i < jobs.length
|
|
680
719
|
job = jobs[i]
|
|
@@ -687,16 +726,16 @@ module Rufio
|
|
|
687
726
|
|
|
688
727
|
def draw_job_log_to_buffer(screen, height, job)
|
|
689
728
|
unless job
|
|
690
|
-
screen.put_string(0,
|
|
729
|
+
screen.put_string(0, JOB_CONTENT_START_LINE, 'No job selected'.ljust(@screen_width), fg: "\e[90m")
|
|
691
730
|
return
|
|
692
731
|
end
|
|
693
732
|
|
|
694
733
|
log_lines = job.logs || []
|
|
695
734
|
title = "=== Log: #{job.name} ==="
|
|
696
|
-
screen.put_string(0,
|
|
735
|
+
screen.put_string(0, JOB_CONTENT_START_LINE, title.ljust(@screen_width), fg: "\e[1;36m")
|
|
697
736
|
|
|
698
737
|
(0...height - 1).each do |i|
|
|
699
|
-
line_num = i +
|
|
738
|
+
line_num = i + JOB_CONTENT_START_LINE + 1
|
|
700
739
|
line = log_lines[i] || ''
|
|
701
740
|
line = line[0...@screen_width].ljust(@screen_width)
|
|
702
741
|
screen.put_string(0, line_num, line, fg: "\e[37m")
|
|
@@ -762,20 +801,9 @@ module Rufio
|
|
|
762
801
|
private
|
|
763
802
|
|
|
764
803
|
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
|
|
804
|
+
return @keybind_handler.get_active_entries if @keybind_handler.respond_to?(:get_active_entries)
|
|
772
805
|
|
|
773
|
-
|
|
774
|
-
if @keybind_handler.help_mode? || @keybind_handler.log_viewer_mode?
|
|
775
|
-
entries.reject { |entry| entry[:name] == '..' }
|
|
776
|
-
else
|
|
777
|
-
entries
|
|
778
|
-
end
|
|
806
|
+
@directory_listing.list_entries
|
|
779
807
|
end
|
|
780
808
|
|
|
781
809
|
# キーバインドハンドラの状態とタブモードを同期
|
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
|