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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +26 -297
  4. data/README_ja.md +24 -261
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/docs/guide.md +410 -0
  9. data/docs/guide_ja.md +410 -0
  10. data/info/help.md +54 -17
  11. data/info/keybindings.md +50 -22
  12. data/info/welcome.md +14 -5
  13. data/lib/rufio/application.rb +12 -1
  14. data/lib/rufio/archive_controller.rb +211 -0
  15. data/lib/rufio/background_command_executor.rb +9 -0
  16. data/lib/rufio/background_status.rb +49 -0
  17. data/lib/rufio/bookmark_controller.rb +38 -121
  18. data/lib/rufio/bookmark_manager.rb +77 -331
  19. data/lib/rufio/command_completion.rb +28 -2
  20. data/lib/rufio/command_history.rb +5 -2
  21. data/lib/rufio/command_mode.rb +60 -134
  22. data/lib/rufio/command_mode_ui.rb +4 -42
  23. data/lib/rufio/config.rb +88 -136
  24. data/lib/rufio/config_loader.rb +4 -11
  25. data/lib/rufio/dialog_renderer.rb +90 -152
  26. data/lib/rufio/dialog_support.rb +69 -0
  27. data/lib/rufio/document_text_extractor.rb +167 -0
  28. data/lib/rufio/file_operation_controller.rb +49 -222
  29. data/lib/rufio/file_operations.rb +28 -6
  30. data/lib/rufio/file_preview.rb +46 -2
  31. data/lib/rufio/global_search.rb +125 -13
  32. data/lib/rufio/help_menu.rb +19 -7
  33. data/lib/rufio/interpreter_resolver.rb +6 -11
  34. data/lib/rufio/job_manager.rb +2 -5
  35. data/lib/rufio/key_input.rb +77 -0
  36. data/lib/rufio/keybind_handler.rb +83 -116
  37. data/lib/rufio/local_script_scanner.rb +6 -18
  38. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  39. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  40. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  41. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  42. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  43. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  44. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  45. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  46. data/lib/rufio/native_content_search.rb +153 -0
  47. data/lib/rufio/native_scanner.rb +13 -68
  48. data/lib/rufio/native_scanner_zig.rb +106 -56
  49. data/lib/rufio/native_zip.rb +121 -0
  50. data/lib/rufio/navigation_controller.rb +20 -26
  51. data/lib/rufio/path_jump.rb +55 -0
  52. data/lib/rufio/screen.rb +2 -12
  53. data/lib/rufio/script_executor.rb +6 -73
  54. data/lib/rufio/script_file.rb +44 -0
  55. data/lib/rufio/script_path_manager.rb +9 -43
  56. data/lib/rufio/script_runner.rb +13 -50
  57. data/lib/rufio/search_controller.rb +1 -1
  58. data/lib/rufio/shell_command_completion.rb +1 -5
  59. data/lib/rufio/shell_init.rb +90 -0
  60. data/lib/rufio/task_status.rb +5 -1
  61. data/lib/rufio/terminal_ui.rb +84 -298
  62. data/lib/rufio/text_utils.rb +26 -3
  63. data/lib/rufio/ui_renderer.rb +73 -45
  64. data/lib/rufio/version.rb +1 -1
  65. data/lib/rufio/zip_archive.rb +314 -0
  66. data/lib/rufio/zip_extractor.rb +346 -0
  67. data/lib/rufio/zip_writer.rb +268 -0
  68. data/lib/rufio/zoxide_integration.rb +5 -43
  69. data/lib/rufio.rb +6 -0
  70. data/lib_zig/rufio_native/Makefile +54 -17
  71. data/lib_zig/rufio_native/bench.sh +36 -0
  72. data/lib_zig/rufio_native/build.zig +32 -35
  73. data/lib_zig/rufio_native/src/cli.zig +99 -0
  74. data/lib_zig/rufio_native/src/main.zig +187 -83
  75. data/lib_zig/rufio_native/src/os.zig +889 -0
  76. data/lib_zig/rufio_native/src/search.zig +919 -0
  77. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  78. data/lib_zig/rufio_native/src/zip.zig +482 -0
  79. metadata +128 -3
  80. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -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
- HEADER_FOOTER_MARGIN = 2 # Header(1行) + Footer(1行) 分のマージン
14
- CONTENT_START_LINE = 1 # コンテンツ開始行(フッタ1行: Y=0)
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
- attr_accessor :completion_lamp_message, :completion_lamp_time
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
- # Completion lamp
68
- @completion_lamp_message = nil
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
- # 通常モード: フッタ y=0(上部)、コンテンツ y=1〜h-2、統合行 y=h-1(下部)
137
- draw_footer_to_buffer(screen, 0, fps)
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
- draw_directory_list_to_buffer(screen, entries, left_width, content_height)
146
- draw_file_preview_to_buffer(screen, selected_entry, right_width, content_height, left_width)
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.instance_variable_get(:@filter_mode)
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 @completion_lamp_message && @completion_lamp_time
522
- if (Time.now - @completion_lamp_time) < 3.0
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
- def draw_mode_tabs_to_buffer(screen, y)
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 + CONTENT_START_LINE
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, CONTENT_START_LINE, 'No job selected'.ljust(@screen_width), fg: "\e[90m")
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, CONTENT_START_LINE, title.ljust(@screen_width), fg: "\e[1;36m")
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 + CONTENT_START_LINE + 1
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
- entries = if @keybind_handler.filter_active?
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
- # ヘルプモードとLogsモードでは..を非表示にする
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rufio
4
- VERSION = '1.1.0'
4
+ VERSION = '1.4.0'
5
5
  end
@@ -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