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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +74 -3
  4. data/README_ja.md +71 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/info/help.md +2 -0
  9. data/info/keybindings.md +3 -1
  10. data/info/welcome.md +1 -1
  11. data/lib/rufio/application.rb +12 -1
  12. data/lib/rufio/archive_controller.rb +211 -0
  13. data/lib/rufio/background_command_executor.rb +9 -0
  14. data/lib/rufio/background_status.rb +49 -0
  15. data/lib/rufio/bookmark_controller.rb +38 -121
  16. data/lib/rufio/bookmark_manager.rb +77 -331
  17. data/lib/rufio/command_completion.rb +6 -1
  18. data/lib/rufio/command_history.rb +5 -2
  19. data/lib/rufio/command_mode.rb +40 -134
  20. data/lib/rufio/command_mode_ui.rb +4 -42
  21. data/lib/rufio/config.rb +88 -136
  22. data/lib/rufio/config_loader.rb +3 -11
  23. data/lib/rufio/dialog_renderer.rb +64 -152
  24. data/lib/rufio/dialog_support.rb +69 -0
  25. data/lib/rufio/document_text_extractor.rb +167 -0
  26. data/lib/rufio/file_operation_controller.rb +49 -222
  27. data/lib/rufio/file_preview.rb +46 -2
  28. data/lib/rufio/global_search.rb +121 -9
  29. data/lib/rufio/help_menu.rb +8 -2
  30. data/lib/rufio/interpreter_resolver.rb +6 -11
  31. data/lib/rufio/job_manager.rb +2 -5
  32. data/lib/rufio/key_input.rb +77 -0
  33. data/lib/rufio/keybind_handler.rb +81 -115
  34. data/lib/rufio/local_script_scanner.rb +6 -18
  35. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  36. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  38. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  39. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  40. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  42. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  43. data/lib/rufio/native_content_search.rb +153 -0
  44. data/lib/rufio/native_scanner.rb +13 -68
  45. data/lib/rufio/native_scanner_zig.rb +106 -56
  46. data/lib/rufio/native_zip.rb +121 -0
  47. data/lib/rufio/navigation_controller.rb +18 -20
  48. data/lib/rufio/screen.rb +2 -12
  49. data/lib/rufio/script_executor.rb +6 -73
  50. data/lib/rufio/script_file.rb +44 -0
  51. data/lib/rufio/script_path_manager.rb +9 -43
  52. data/lib/rufio/script_runner.rb +13 -50
  53. data/lib/rufio/search_controller.rb +1 -1
  54. data/lib/rufio/shell_command_completion.rb +1 -5
  55. data/lib/rufio/shell_init.rb +90 -0
  56. data/lib/rufio/task_status.rb +5 -1
  57. data/lib/rufio/terminal_ui.rb +55 -294
  58. data/lib/rufio/text_utils.rb +26 -3
  59. data/lib/rufio/ui_renderer.rb +63 -40
  60. data/lib/rufio/version.rb +1 -1
  61. data/lib/rufio/zip_archive.rb +314 -0
  62. data/lib/rufio/zip_extractor.rb +346 -0
  63. data/lib/rufio/zip_writer.rb +268 -0
  64. data/lib/rufio/zoxide_integration.rb +5 -43
  65. data/lib/rufio.rb +5 -0
  66. data/lib_zig/rufio_native/Makefile +54 -17
  67. data/lib_zig/rufio_native/bench.sh +36 -0
  68. data/lib_zig/rufio_native/build.zig +32 -35
  69. data/lib_zig/rufio_native/src/cli.zig +99 -0
  70. data/lib_zig/rufio_native/src/main.zig +187 -83
  71. data/lib_zig/rufio_native/src/os.zig +889 -0
  72. data/lib_zig/rufio_native/src/search.zig +919 -0
  73. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  74. data/lib_zig/rufio_native/src/zip.zig +482 -0
  75. metadata +125 -3
  76. 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]
@@ -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.instance_variable_get(:@filter_mode)
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 @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
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
- def draw_mode_tabs_to_buffer(screen, y)
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 + CONTENT_START_LINE
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, CONTENT_START_LINE, 'No job selected'.ljust(@screen_width), fg: "\e[90m")
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, CONTENT_START_LINE, title.ljust(@screen_width), fg: "\e[1;36m")
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 + CONTENT_START_LINE + 1
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
- 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
799
+ return @keybind_handler.get_active_entries if @keybind_handler.respond_to?(:get_active_entries)
772
800
 
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
801
+ @directory_listing.list_entries
779
802
  end
780
803
 
781
804
  # キーバインドハンドラの状態とタブモードを同期
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.3.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