vanken 0.1.0 → 0.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +28 -11
  4. data/data/coloring_rules.yml +67 -0
  5. data/docs/performance.md +53 -0
  6. data/docs/releases.md +2 -4
  7. data/docs/usage.md +51 -0
  8. data/exe/vanken-setup-permissions +6 -0
  9. data/lib/vanken/app/capture_controller.rb +66 -1
  10. data/lib/vanken/app/document.rb +102 -39
  11. data/lib/vanken/app/document_analysis.rb +75 -0
  12. data/lib/vanken/app/document_jobs.rb +52 -9
  13. data/lib/vanken/app/expert_info.rb +26 -0
  14. data/lib/vanken/app/io_graph.rb +47 -0
  15. data/lib/vanken/app/navigation.rb +77 -0
  16. data/lib/vanken/app/search_job.rb +62 -0
  17. data/lib/vanken/app/stats_job.rb +45 -0
  18. data/lib/vanken/capture/analyzer.rb +3 -1
  19. data/lib/vanken/capture/analyzer_process.rb +9 -6
  20. data/lib/vanken/capture/analyzer_worker.rb +2 -2
  21. data/lib/vanken/capture/filter_worker.rb +8 -2
  22. data/lib/vanken/capture/helper_main.rb +10 -3
  23. data/lib/vanken/capture/helper_options.rb +6 -2
  24. data/lib/vanken/capture/launcher.rb +1 -0
  25. data/lib/vanken/capture/permission_setup.rb +201 -0
  26. data/lib/vanken/config/analysis_settings.rb +58 -0
  27. data/lib/vanken/config/columns.rb +138 -0
  28. data/lib/vanken/config/messages.rb +105 -0
  29. data/lib/vanken/config/preferences.rb +34 -8
  30. data/lib/vanken/config/profiles.rb +74 -0
  31. data/lib/vanken/config/sessions.rb +69 -0
  32. data/lib/vanken/config/yaml_file.rb +36 -0
  33. data/lib/vanken/core/coloring.rb +86 -0
  34. data/lib/vanken/core/frame_store.rb +2 -2
  35. data/lib/vanken/core/stores.rb +12 -0
  36. data/lib/vanken/gateway/detail_builder.rb +1 -1
  37. data/lib/vanken/gateway/display_capture_filter.rb +102 -0
  38. data/lib/vanken/gateway/dissector.rb +31 -6
  39. data/lib/vanken/gateway/exporter.rb +125 -0
  40. data/lib/vanken/gateway/file_writer.rb +26 -2
  41. data/lib/vanken/gateway/interfaces.rb +59 -0
  42. data/lib/vanken/gateway/resolver.rb +131 -0
  43. data/lib/vanken/gateway/statistics.rb +83 -0
  44. data/lib/vanken/gateway/stream.rb +159 -0
  45. data/lib/vanken/ui/actions.rb +54 -28
  46. data/lib/vanken/ui/analysis_dialogs.rb +371 -0
  47. data/lib/vanken/ui/application.rb +183 -9
  48. data/lib/vanken/ui/coloring_operations.rb +104 -0
  49. data/lib/vanken/ui/column_operations.rb +115 -0
  50. data/lib/vanken/ui/dialogs.rb +41 -26
  51. data/lib/vanken/ui/file_operations.rb +17 -12
  52. data/lib/vanken/ui/filter_operations.rb +43 -15
  53. data/lib/vanken/ui/main_view.rb +74 -29
  54. data/lib/vanken/ui/navigation_operations.rb +86 -0
  55. data/lib/vanken/ui/packet_source.rb +33 -1
  56. data/lib/vanken/ui/selection.rb +8 -2
  57. data/lib/vanken/ui/settings_operations.rb +233 -0
  58. data/lib/vanken/version.rb +1 -1
  59. data/packaging/README.md +41 -1
  60. data/packaging/linux/org.vanken.capture.policy +14 -0
  61. data/packaging/linux/vanken-capture.wrapper +6 -0
  62. data/packaging/linux/vanken.desktop +9 -0
  63. data/packaging/linux/vanken.sudoers +2 -0
  64. data/packaging/macos/chmod-bpf +6 -0
  65. data/packaging/macos/org.vanken.chmod-bpf.plist +8 -0
  66. data/sig/generated/vanken/app/capture_controller.rbs +16 -0
  67. data/sig/generated/vanken/app/document.rbs +23 -1
  68. data/sig/generated/vanken/app/document_analysis.rbs +21 -0
  69. data/sig/generated/vanken/app/document_jobs.rbs +6 -0
  70. data/sig/generated/vanken/app/expert_info.rbs +30 -0
  71. data/sig/generated/vanken/app/io_graph.rbs +45 -0
  72. data/sig/generated/vanken/app/navigation.rbs +31 -0
  73. data/sig/generated/vanken/app/search_job.rbs +21 -0
  74. data/sig/generated/vanken/app/stats_job.rbs +18 -0
  75. data/sig/generated/vanken/capture/helper_options.rbs +6 -0
  76. data/sig/generated/vanken/capture/permission_setup.rbs +43 -0
  77. data/sig/generated/vanken/config/analysis_settings.rbs +31 -0
  78. data/sig/generated/vanken/config/columns.rbs +37 -0
  79. data/sig/generated/vanken/config/messages.rbs +14 -0
  80. data/sig/generated/vanken/config/preferences.rbs +1 -1
  81. data/sig/generated/vanken/config/profiles.rbs +29 -0
  82. data/sig/generated/vanken/config/sessions.rbs +17 -0
  83. data/sig/generated/vanken/config/yaml_file.rbs +11 -0
  84. data/sig/generated/vanken/core/coloring.rbs +52 -0
  85. data/sig/generated/vanken/core/stores.rbs +4 -0
  86. data/sig/generated/vanken/gateway/display_capture_filter.rbs +37 -0
  87. data/sig/generated/vanken/gateway/dissector.rbs +15 -2
  88. data/sig/generated/vanken/gateway/exporter.rbs +27 -0
  89. data/sig/generated/vanken/gateway/file_writer.rbs +3 -0
  90. data/sig/generated/vanken/gateway/interfaces.rbs +12 -0
  91. data/sig/generated/vanken/gateway/resolver.rbs +52 -0
  92. data/sig/generated/vanken/gateway/statistics.rbs +104 -0
  93. data/sig/generated/vanken/gateway/stream.rbs +75 -0
  94. data/sig/generated/vanken/ui/analysis_dialogs.rbs +71 -0
  95. data/sig/generated/vanken/ui/application.rbs +23 -1
  96. data/sig/generated/vanken/ui/coloring_operations.rbs +29 -0
  97. data/sig/generated/vanken/ui/column_operations.rbs +29 -0
  98. data/sig/generated/vanken/ui/dialogs.rbs +5 -3
  99. data/sig/generated/vanken/ui/file_operations.rbs +2 -0
  100. data/sig/generated/vanken/ui/filter_operations.rbs +3 -1
  101. data/sig/generated/vanken/ui/main_view.rbs +14 -2
  102. data/sig/generated/vanken/ui/navigation_operations.rbs +43 -0
  103. data/sig/generated/vanken/ui/packet_source.rbs +4 -0
  104. data/sig/generated/vanken/ui/settings_operations.rbs +41 -0
  105. metadata +61 -3
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "tempfile"
5
+ require_relative "dissector"
6
+
7
+ module Vanken
8
+ module Gateway
9
+ StreamChunk = Data.define(
10
+ :direction, #: Integer
11
+ :bytes, #: String
12
+ :missing, #: Integer
13
+ :number, #: Integer
14
+ :sequence #: Integer
15
+ )
16
+ StreamText = Data.define(
17
+ :direction, #: Integer
18
+ :text #: String
19
+ )
20
+
21
+ class Stream
22
+ DISPLAY_LIMIT = 16 << 20 #: Integer
23
+ SEQUENCE_MOD = 1 << 32 #: Integer
24
+ attr_reader :nodes #: Array[String]
25
+ attr_reader :chunks #: Array[StreamChunk]
26
+ attr_reader :stream_id #: Integer
27
+
28
+ # @rbs (untyped document, Integer stream_id, ?cancelled: (^() -> bool)?) -> void
29
+ def initialize(document, stream_id, cancelled: nil)
30
+ raise ArgumentError, "invalid stream number" unless stream_id.is_a?(Integer) && stream_id >= 0
31
+ @stream_id, @nodes, @chunks = stream_id, [], []
32
+ pieces = [[], []] #: Array[Array[StreamChunk]]
33
+ starts = [nil, nil] #: Array[Integer?]
34
+ frontiers = [0, 0]
35
+ frames = document.stream_frames(stream_id)
36
+ frames.each do |number|
37
+ raise Vanken::Error, "operation cancelled" if cancelled&.call || document.closing?
38
+ packet = document.packet(number)
39
+ tcp = packet.layers.find { |layer| layer.protocol == :tcp && !layer.embedded }
40
+ next unless tcp
41
+ columns = packet.columns
42
+ endpoints = [endpoint(columns[:source], columns[:src_port]), endpoint(columns[:destination], columns[:dst_port])]
43
+ @nodes = tcp[:flags] & 0x12 == 0x12 ? endpoints.reverse : endpoints if @nodes.empty?
44
+ direction = endpoints == @nodes ? 0 : 1
45
+ sequence = document.annotations[number][:seq_rel]
46
+ difference = (sequence - frontiers[direction]) % SEQUENCE_MOD
47
+ difference -= SEQUENCE_MOD if difference >= SEQUENCE_MOD / 2
48
+ sequence = frontiers[direction] + difference
49
+ sequence += 1 if tcp[:flags] & 2 != 0
50
+ starts[direction] ||= sequence if tcp[:flags] & 2 != 0
51
+ data = packet.bytes.byteslice(tcp.payload_offset, tcp.payload_end - tcp.payload_offset)
52
+ frontiers[direction] = [frontiers[direction], sequence + data.bytesize].max
53
+ next if data.empty?
54
+ insert(pieces[direction], StreamChunk.new(direction: direction, bytes: data, missing: 0, number: number, sequence: sequence))
55
+ end
56
+ directions = pieces.each_with_index.map do |segments, direction|
57
+ cursor = starts[direction]
58
+ segments.flat_map do |segment|
59
+ missing = cursor && segment.sequence > cursor ? segment.sequence - cursor : 0
60
+ cursor = segment.sequence + segment.bytes.bytesize
61
+ gap = StreamChunk.new(direction: direction, bytes: "".b, missing: missing, number: segment.number, sequence: segment.sequence - missing)
62
+ missing.positive? ? [gap, segment] : [segment]
63
+ end
64
+ end
65
+ positions = [0, 0]
66
+ until positions.each_with_index.all? { |index, direction| index == directions[direction].size }
67
+ direction = [0, 1].reject { |value| positions[value] == directions[value].size }.min_by do |value|
68
+ directions[value][positions[value]].number
69
+ end
70
+ raise Vanken::Error, "invalid stream direction" unless direction
71
+ @chunks << directions[direction][positions[direction]]
72
+ positions[direction] += 1
73
+ end
74
+ @nodes.freeze
75
+ @chunks.freeze
76
+ end
77
+
78
+ # @rbs (?direction: Integer?) -> Integer
79
+ def byte_size(direction: nil) = selected(direction).sum { |chunk| chunk.bytes.bytesize }
80
+
81
+ # @rbs (?format: Symbol, ?direction: Integer?, ?limit: Integer) -> Array[StreamText]
82
+ def preview(format: :ascii, direction: nil, limit: DISPLAY_LIMIT)
83
+ validate(format, direction)
84
+ raise ArgumentError, "invalid display limit" unless limit.is_a?(Integer) && limit >= 0
85
+ remaining = limit
86
+ result = [] #: Array[StreamText]
87
+ selected(direction).each do |chunk|
88
+ break if remaining.zero?
89
+ bytes = chunk.bytes.byteslice(0, remaining) || "".b
90
+ remaining -= bytes.bytesize
91
+ result << StreamText.new(direction: chunk.direction, text: render(chunk, bytes, format))
92
+ end
93
+ result
94
+ end
95
+
96
+ # @rbs (String path, ?format: Symbol, ?direction: Integer?, ?cancelled: (^() -> bool)?) -> String
97
+ def save(path, format: :raw, direction: nil, cancelled: nil)
98
+ validate(format, direction)
99
+ file = Tempfile.create([".vanken-stream-", ".tmp"], File.dirname(File.expand_path(path)))
100
+ begin
101
+ file.binmode
102
+ selected(direction).each do |chunk|
103
+ raise Vanken::Error, "operation cancelled" if cancelled&.call
104
+ file.write(render(chunk, chunk.bytes, format))
105
+ end
106
+ file.flush
107
+ file.fsync
108
+ file.close
109
+ File.rename(file.path, File.expand_path(path))
110
+ ensure
111
+ file.close unless file.closed?
112
+ FileUtils.rm_f(file.path)
113
+ end
114
+ path
115
+ end
116
+
117
+ private
118
+
119
+ # @rbs (Integer? direction) -> Array[StreamChunk]
120
+ def selected(direction) = @chunks.select { |chunk| direction.nil? || chunk.direction == direction }
121
+ def endpoint(address, port) = address.include?(":") ? "[#{address}]:#{port}" : "#{address}:#{port}"
122
+ def validate(format, direction)
123
+ raise ArgumentError, "invalid stream format" unless %i[ascii hex raw].include?(format)
124
+ raise ArgumentError, "invalid stream direction" unless [nil, 0, 1].include?(direction)
125
+ end
126
+
127
+ def render(chunk, bytes, format)
128
+ return "".b if format == :raw && chunk.missing.positive?
129
+ return "[#{chunk.missing} bytes missing]" if chunk.missing.positive?
130
+ return bytes if format == :raw
131
+ return bytes.gsub(/[^\x20-\x7e\r\n\t]/n) { |byte| Kernel.format("\\x%02x", byte.getbyte(0)) } if format == :ascii
132
+ bytes.bytes.each_slice(16).with_index.map do |row, index|
133
+ Kernel.format("%08x %s\n", chunk.sequence + (index * 16), row.map { |byte| Kernel.format("%02x", byte) }.join(" "))
134
+ end.join
135
+ end
136
+
137
+ # Keep capture arrival precedence while inserting only previously unseen ranges.
138
+ def insert(segments, chunk)
139
+ first, last = chunk.sequence, chunk.sequence + chunk.bytes.bytesize
140
+ index = segments.bsearch_index { |segment| segment.sequence + segment.bytes.bytesize > first } || segments.size
141
+ uncovered = [] #: Array[[Integer, Integer]]
142
+ cursor = first
143
+ while index < segments.size && segments[index].sequence < last
144
+ previous = segments[index]
145
+ uncovered << [cursor, [previous.sequence, last].min] if previous.sequence > cursor
146
+ cursor = [cursor, previous.sequence + previous.bytes.bytesize].max
147
+ index += 1
148
+ end
149
+ uncovered << [cursor, last] if cursor < last
150
+ uncovered.each do |start, finish|
151
+ segment = chunk.with(sequence: start, bytes: chunk.bytes.byteslice(start - first, finish - start))
152
+ position = segments.bsearch_index { |previous| previous.sequence > start } || segments.size
153
+ # ponytail: array insertion is O(n) for heavily reordered streams; use an interval tree if measured.
154
+ segments.insert(position, segment)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -5,44 +5,70 @@ module Vanken
5
5
  module Actions
6
6
  def self.install(ui)
7
7
  commands = {
8
- open: ["開く", "cmd-o", -> { ui.open_dialog }], save: ["名前を付けて保存", "cmd-shift-s", -> { ui.save_dialog }],
9
- close_file: ["ファイルを閉じる", "cmd-w", -> { ui.close_document }], reload: ["再読み込み", "cmd-r", -> { ui.reload_file }],
10
- quit: ["終了", "cmd-q", -> { ui.quit }], preferences: ["設定", "cmd-,", -> { ui.preferences_dialog }],
11
- capture: ["キャプチャ開始/停止", "cmd-e", -> { ui.capture_toggle }], restart: ["キャプチャ再開", nil, -> { ui.restart_capture }],
12
- apply_filter: ["フィルタを適用", "enter", -> { ui.apply_filter }], clear_filter: ["フィルタをクリア", nil, -> { ui.clear_filter }],
13
- filter_history: ["フィルタ履歴", nil, -> { ui.filter_history }], bookmark: ["フィルタを保存", nil, -> { ui.bookmark_filter }],
14
- filter_selected: ["選択した項目でフィルタ", nil, -> { ui.filter_from_selection }],
15
- prepare_selected: ["選択した項目のフィルタを準備", nil, -> { ui.filter_from_selection(prepare: true) }],
16
- columns: ["表示する列", nil, -> { ui.columns_dialog }], autoscroll: ["末尾追従", nil, -> { ui.toggle_autoscroll }],
17
- zoom_in: ["拡大", "cmd-+", -> { ui.zoom(1) }], zoom_out: ["縮小", "cmd--", -> { ui.zoom(-1) }], zoom_reset: ["標準サイズ", "cmd-0", -> { ui.zoom }],
18
- previous_packet: ["前のパケット", "alt-up", -> { ui.navigate(-1) }], next_packet: ["次のパケット", "alt-down", -> { ui.navigate(1) }],
19
- first_packet: ["最初のパケット", nil, -> { ui.first_packet }], last_packet: ["最後のパケット", nil, -> { ui.last_packet }],
20
- go_to_packet: ["パケットへ移動", "cmd-g", -> { ui.go_to_packet }], copy_filter: ["項目をフィルタとしてコピー", nil, -> { ui.copy_text(ui.selected_node.filter) if ui.selected_node&.filter }]
8
+ open: [ui.t("開く"), "cmd-o", -> { ui.open_dialog }], save: [ui.t("名前を付けて保存"), "cmd-shift-s", -> { ui.save_dialog }],
9
+ close_file: [ui.t("ファイルを閉じる"), "cmd-w", -> { ui.close_document }], reload: [ui.t("再読み込み"), "cmd-r", -> { ui.reload_file }],
10
+ quit: [ui.t("終了"), "cmd-q", -> { ui.quit }], preferences: [ui.t("設定"), "cmd-,", -> { ui.preferences_dialog }],
11
+ capture: [ui.t("キャプチャ開始/停止"), "cmd-e", -> { ui.capture_toggle }], restart: [ui.t("キャプチャ再開"), nil, -> { ui.restart_capture }],
12
+ apply_filter: [ui.t("フィルタを適用"), "enter", -> { ui.apply_filter }], clear_filter: [ui.t("フィルタをクリア"), nil, -> { ui.clear_filter }],
13
+ filter_history: [ui.t("フィルタ履歴"), nil, -> { ui.filter_history }], bookmark: [ui.t("フィルタを保存"), nil, -> { ui.bookmark_filter }],
14
+ filter_selected: [ui.t("選択した項目でフィルタ"), nil, -> { ui.filter_from_selection }],
15
+ prepare_selected: [ui.t("選択した項目のフィルタを準備"), nil, -> { ui.filter_from_selection(prepare: true) }],
16
+ columns: [ui.t("表示する列"), nil, -> { ui.columns_dialog }], autoscroll: [ui.t("末尾追従"), nil, -> { ui.toggle_autoscroll }],
17
+ zoom_in: [ui.t("拡大"), "cmd-+", -> { ui.zoom(1) }], zoom_out: [ui.t("縮小"), "cmd--", -> { ui.zoom(-1) }], zoom_reset: [ui.t("標準サイズ"), "cmd-0", -> { ui.zoom }],
18
+ previous_packet: [ui.t("前のパケット"), "alt-up", -> { ui.navigate(-1) }], next_packet: [ui.t("次のパケット"), "alt-down", -> { ui.navigate(1) }],
19
+ first_packet: [ui.t("最初のパケット"), nil, -> { ui.first_packet }], last_packet: [ui.t("最後のパケット"), nil, -> { ui.last_packet }],
20
+ go_to_packet: [ui.t("パケットへ移動"), "cmd-g", -> { ui.go_to_packet }], copy_filter: [ui.t("項目をフィルタとしてコピー"), nil, -> { ui.copy_text(ui.selected_node.filter) if ui.selected_node&.filter }]
21
21
  }
22
- %i[selected not and and_not or or_not].zip(["選択項目", "選択項目を除外", "AND", "AND NOT", "OR", "OR NOT"]).each do |mode, label|
23
- commands[:"filter_#{mode}"] = ["適用: #{label}", nil, -> { ui.filter_from_selection(mode) }]
24
- commands[:"prepare_#{mode}"] = ["準備: #{label}", nil, -> { ui.filter_from_selection(mode, prepare: true) }]
22
+ commands.merge!(
23
+ find_packet: [ui.t("パケット検索"), "cmd-f", -> { ui.find_packet }], find_next: [ui.t("次を検索"), "cmd-n", -> { ui.find_next }], find_prev: [ui.t("前を検索"), "cmd-b", -> { ui.find_prev }],
24
+ mark_packet: [ui.t("マーク/解除"), "cmd-m", -> { ui.mark_packet }], mark_all_displayed: [ui.t("表示中をすべてマーク"), "cmd-shift-m", -> { ui.mark_all_displayed }], unmark_all: [ui.t("すべてのマークを解除"), "cmd-alt-m", -> { ui.unmark_all }],
25
+ ignore_packet: [ui.t("無視/解除"), "cmd-d", -> { ui.ignore_packet }], time_reference: [ui.t("時刻基準/解除"), "cmd-t", -> { ui.time_reference }],
26
+ prev_in_conversation: [ui.t("同一会話の前"), "cmd-,", -> { ui.prev_in_conversation }], next_in_conversation: [ui.t("同一会話の次"), "cmd-.", -> { ui.next_in_conversation }],
27
+ coloring_rules: [ui.t("色付けルール"), nil, -> { ui.coloring_dialog }], toggle_coloring: [ui.t("色付け"), nil, -> { ui.toggle_coloring }],
28
+ follow_tcp_stream: [ui.t("TCP ストリームを追跡"), "cmd-alt-shift-t", -> { ui.follow_tcp_stream }], decode_as: [ui.t("別プロトコルとして解析"), "cmd-shift-u", -> { ui.decode_as }],
29
+ expert_info: [ui.t("エキスパート情報"), nil, -> { ui.expert_info }], reload_plugins: [ui.t("プラグイン再読み込み"), "cmd-shift-l", -> { ui.reload_plugins }], plugins: [ui.t("ディセクタプラグイン"), nil, -> { ui.plugins_dialog }],
30
+ file_properties: [ui.t("キャプチャファイルのプロパティ"), "cmd-alt-shift-c", -> { ui.file_properties }], protocol_hierarchy: [ui.t("プロトコル階層"), nil, -> { ui.protocol_hierarchy }],
31
+ conversations: [ui.t("会話"), nil, -> { ui.conversations }], endpoints: [ui.t("端点"), nil, -> { ui.endpoints }], io_graph: [ui.t("I/O グラフ"), nil, -> { ui.io_graph }],
32
+ export_packets: [ui.t("指定パケットのエクスポート"), nil, -> { ui.export_packets }], export_dissections: [ui.t("解析結果のエクスポート"), nil, -> { ui.export_dissections }],
33
+ command_palette: [ui.t("コマンドパレット"), "cmd-shift-k", -> { ui.command_palette }], profiles: [ui.t("プロファイル"), nil, -> { ui.profiles_dialog }])
34
+ commands[:preferences][1] = "cmd-shift-p"
35
+ commands[:restart][1] = "cmd-shift-r"
36
+ {"relative" => "相対時刻", "absolute" => "絶対時刻", "delta" => "前パケットとの差", "delta_displayed" => "前表示パケットとの差", "epoch" => "エポック時刻"}.each { |name, label| commands[:"time_format_#{name}"] = [ui.t("時刻: %{name}", name: ui.t(label)), nil, -> { ui.time_format(name) }] }
37
+ {"system" => "システム設定", "dark" => "ダーク", "light" => "ライト", "high_contrast" => "ハイコントラスト"}.each { |name, label| commands[:"theme_#{name}"] = [ui.t("テーマ: %{name}", name: ui.t(label)), nil, -> { ui.change_theme(name) }] }
38
+ %i[selected not and and_not or or_not].zip([ui.t("選択項目"), ui.t("選択項目を除外"), "AND", "AND NOT", "OR", "OR NOT"]).each do |mode, label|
39
+ commands[:"filter_#{mode}"] = [ui.t("適用: %{label}", label: label), nil, -> { ui.filter_from_selection(mode) }]
40
+ commands[:"prepare_#{mode}"] = [ui.t("準備: %{label}", label: label), nil, -> { ui.filter_from_selection(mode, prepare: true) }]
25
41
  end
26
- {hex: "16 進数", text: "テキスト", dump: "ダンプ", escaped: "エスケープした文字列"}.each do |format, label|
27
- commands[:"copy_#{format}"] = ["バイトをコピー: #{label}", nil, -> { ui.copy_bytes(format) }]
42
+ {hex: ui.t("16 進数"), text: ui.t("テキスト"), dump: ui.t("ダンプ"), escaped: ui.t("エスケープした文字列")}.each do |format, label|
43
+ commands[:"copy_#{format}"] = [ui.t("バイトをコピー: %{label}", label: label), nil, -> { ui.copy_bytes(format) }]
28
44
  end
29
45
  commands.each do |name, (title, keys, operation)|
30
- ui.app.actions.register(name, title: title) { |_| operation.call }
46
+ needs_document = %i[find_packet find_next find_prev mark_packet mark_all_displayed unmark_all ignore_packet time_reference prev_in_conversation next_in_conversation follow_tcp_stream expert_info file_properties protocol_hierarchy conversations endpoints io_graph export_packets export_dissections]
47
+ enabled = ->(*) { !needs_document.include?(name) || !!ui.document }
48
+ checked = name == :autoscroll ? ->(*) { ui.autoscroll? } : name == :toggle_coloring ? ->(*) { ui.coloring_enabled? } : nil
49
+ ui.app.actions.register(name, title: title, enabled: enabled, checked: checked) { |_| operation.call }
31
50
  next unless keys
32
- key = RUBY_PLATFORM.include?("darwin") ? keys : keys.sub("cmd", "ctrl")
51
+ key = ui.native? && RUBY_PLATFORM.include?("darwin") ? keys : keys.sub("cmd", "ctrl")
33
52
  ui.window.dispatcher.keymap.bind(key, name, context: name == :apply_filter ? "in_display_filter && !in_completion" : "")
34
53
  end
35
54
  ui.app.menu_bar = Zaniah::Menu.build do
36
- submenu("ファイル") { %i[open save close_file reload quit].each { |action| item(action) } }
37
- submenu("編集") { %i[copy_filter copy_hex copy_text copy_dump copy_escaped preferences].each { |action| item(action) } }
38
- submenu("表示") { %i[columns autoscroll zoom_in zoom_out zoom_reset].each { |action| item(action) } }
39
- submenu("移動") { %i[previous_packet next_packet first_packet last_packet go_to_packet].each { |action| item(action) } }
40
- submenu("キャプチャ") { %i[capture restart].each { |action| item(action) } }
41
- submenu("フィルタ") do
55
+ submenu(ui.t("ファイル")) { %i[open save close_file reload export_packets export_dissections quit].each { |action| item(action) } }
56
+ submenu(ui.t("編集")) { %i[find_packet find_next find_prev mark_packet mark_all_displayed unmark_all ignore_packet time_reference copy_filter copy_hex copy_text copy_dump copy_escaped preferences profiles].each { |action| item(action) } }
57
+ submenu(ui.t("表示")) do
58
+ %i[columns autoscroll coloring_rules toggle_coloring zoom_in zoom_out zoom_reset].each { |action| item(action) }
59
+ submenu(ui.t("時刻形式")) { %w[relative absolute delta delta_displayed epoch].each { |name| item(:"time_format_#{name}") } }
60
+ submenu(ui.t("テーマ")) { %w[system dark light high_contrast].each { |name| item(:"theme_#{name}") } }
61
+ end
62
+ submenu(ui.t("移動")) { %i[previous_packet next_packet first_packet last_packet go_to_packet prev_in_conversation next_in_conversation].each { |action| item(action) } }
63
+ submenu(ui.t("キャプチャ")) { %i[capture restart].each { |action| item(action) } }
64
+ submenu(ui.t("フィルタ")) do
42
65
  %i[apply_filter clear_filter filter_history bookmark].each { |action| item(action) }
43
- submenu("選択項目を適用") { %i[selected not and and_not or or_not].each { |mode| item(:"filter_#{mode}") } }
44
- submenu("選択項目から準備") { %i[selected not and and_not or or_not].each { |mode| item(:"prepare_#{mode}") } }
66
+ submenu(ui.t("選択項目を適用")) { %i[selected not and and_not or or_not].each { |mode| item(:"filter_#{mode}") } }
67
+ submenu(ui.t("選択項目から準備")) { %i[selected not and and_not or or_not].each { |mode| item(:"prepare_#{mode}") } }
45
68
  end
69
+ submenu(ui.t("分析")) { %i[follow_tcp_stream decode_as expert_info plugins reload_plugins].each { |action| item(action) } }
70
+ submenu(ui.t("統計")) { %i[file_properties protocol_hierarchy conversations endpoints io_graph].each { |action| item(action) } }
71
+ submenu(ui.t("ヘルプ")) { item(:command_palette) }
46
72
  end
47
73
  end
48
74
  end
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../app/stats_job"
5
+ require_relative "../app/expert_info"
6
+ require_relative "../app/io_graph"
7
+ require_relative "../gateway/stream"
8
+ require_relative "../gateway/exporter"
9
+
10
+ module Vanken
11
+ module UI
12
+ class AnalysisRows
13
+ def initialize(rows) = (@rows = rows)
14
+ def count = @rows.size
15
+ def row_id(index) = index
16
+ def cell(index, key)
17
+ value = @rows.fetch(index).to_h[key]
18
+ value.is_a?(Array) ? value.join(" / ") : value
19
+ end
20
+ def sort(key, direction)
21
+ @rows.sort_by! { |row| row.to_h[key] || "" }
22
+ @rows.reverse! if direction == :desc
23
+ end
24
+ def [](index) = @rows.fetch(index)
25
+ end
26
+
27
+ module AnalysisDialogs
28
+ ANALYSIS_KINDS = %i[protocol_hierarchy conversations endpoints expert_info io_graph follow_tcp_stream file_properties].freeze
29
+
30
+ def close_analysis
31
+ @analysis_generation = (@analysis_generation || 0) + 1
32
+ @statistics_job&.cancel
33
+ @statistics_job = nil
34
+ # Await actual completion; Task#cancel alone does not join an executing worker.
35
+ @analysis_task&.await
36
+ rescue StandardError
37
+ nil
38
+ ensure
39
+ @analysis_task = @analysis_results = @analysis_refresh = nil
40
+ @follow_stream = @follow_text = nil
41
+ @analysis_running = false
42
+ end
43
+
44
+ def refresh_analysis
45
+ return unless @analysis_refresh && ANALYSIS_KINDS.include?(@dialog_kind) && !@analysis_running && !@closing
46
+ @analysis_refresh.call
47
+ end
48
+
49
+ def protocol_hierarchy = statistics_dialog(:protocol_hierarchy)
50
+ def conversations = statistics_dialog(:conversations)
51
+ def endpoints = statistics_dialog(:endpoints)
52
+
53
+ def statistics_dialog(kind, displayed_only: false)
54
+ return unless document
55
+ types = %w[eth ip ipv6 tcp udp]
56
+ specs = kind == :protocol_hierarchy ? ["phs"] : types.map { |type| "#{kind == :conversations ? 'conv' : 'endpoints'},#{type}" }
57
+ controls = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only).test_id("vk.stats.displayed")
58
+ .on_change { |value, *_| statistics_dialog(kind, displayed_only: value) }
59
+ analysis_dialog(kind, {protocol_hierarchy: t("プロトコル階層"), conversations: t("会話"), endpoints: t("端点")}.fetch(kind), controls, reopen: -> { statistics_dialog(kind, displayed_only: displayed_only) })
60
+ @statistics_job = App::StatsJob.new(document, specs: specs, displayed_only: displayed_only)
61
+ job = @statistics_job
62
+ @analysis_refresh = lambda do
63
+ analysis_background(->(*) { job.refresh }) do |tables|
64
+ if kind == :protocol_hierarchy
65
+ rows = tables.first.rows
66
+ total = rows.select { |row| row.path.size == 1 }.sum(&:packets)
67
+ nodes = rows.to_h do |row|
68
+ id = row.path.join("/")
69
+ label = t("%{protocol} %{packets} パケット %{bytes} bytes %{percent}%", protocol: row.path.last, packets: row.packets, bytes: row.bytes, percent: total.zero? ? 0 : (row.packets * 100.0 / total).round(1))
70
+ [id, Core::DetailNode.new(id: id, label: label, field: nil, offset: nil, length: 0, source: :frame, severity: nil, filter: nil, children: [])]
71
+ end
72
+ roots = []
73
+ rows.each do |row|
74
+ node = nodes.fetch(row.path.join("/"))
75
+ parent = nodes[row.path.take(row.path.size - 1).join("/")]
76
+ parent ? parent.children << node : roots << node
77
+ end
78
+ analysis_content(Zaniah::UI::TreeView.new(roots, height: 300, label: t("プロトコル階層")).test_id("vk.stats.hierarchy"))
79
+ else
80
+ tabs = tables.map { |table| [table.type.to_s.upcase, statistics_table(table)] }
81
+ analysis_content(Zaniah::UI::Tabs.new(tabs, selected: @statistics_tab || 0)
82
+ .on_change { |index, *_| @statistics_tab = index }.test_id("vk.stats.tabs"))
83
+ end
84
+ end
85
+ end
86
+ refresh_analysis
87
+ end
88
+
89
+ def expert_info(displayed_only: false)
90
+ return unless document
91
+ controls = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only)
92
+ .on_change { |value, *_| expert_info(displayed_only: value) }
93
+ analysis_dialog(:expert_info, t("エキスパート情報"), controls, reopen: -> { expert_info(displayed_only: displayed_only) })
94
+ @analysis_refresh = lambda do
95
+ analysis_background(->(doc, *) { App::ExpertInfo.rows(doc, displayed_only: displayed_only) }) do |rows|
96
+ table = analysis_table(rows, %i[severity code protocol count message]) do |row, event|
97
+ select_packet(row.numbers.first) if event&.click_count.to_i >= 2
98
+ end.test_id("vk.expert.table")
99
+ analysis_content(table)
100
+ end
101
+ end
102
+ refresh_analysis
103
+ end
104
+
105
+ def io_graph(interval: 1, expressions: [""], displayed_only: false)
106
+ return unless document
107
+ interval_control = Zaniah::UI::Select.new(App::IOGraph::INTERVALS.map { |value| [t("%{value}秒", value: value), value] }, value: interval, label: t("間隔"))
108
+ .test_id("vk.io.interval")
109
+ filters = Zaniah::UI::TextArea.new(expressions.join("\n"), rows: 2, label: t("系列の表示フィルタ(1行に1系列、空行は全件)")).test_id("vk.io.series")
110
+ displayed = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only)
111
+ redraw = lambda { io_graph(interval: interval, expressions: filters.value.split("\n", -1).uniq, displayed_only: displayed.value) }
112
+ interval_control.on_change { |value, *_| interval = value; redraw.call }
113
+ displayed.on_change { redraw.call }
114
+ controls = Zaniah::Div.new.flex_col.gap(6).child(interval_control).child(filters).child(displayed)
115
+ .child(Zaniah::UI::Button.new(t("系列を適用")).test_id("vk.io.apply").on_click { redraw.call })
116
+ analysis_dialog(:io_graph, t("I/O グラフ"), controls, reopen: redraw)
117
+ @analysis_refresh = lambda do
118
+ analysis_background(->(doc, cancelled) { App::IOGraph.build(doc, interval: interval, series: expressions, displayed_only: displayed_only, cancelled: cancelled) }) do |series|
119
+ if series.first.points.empty?
120
+ analysis_content(Zaniah::UI::Label.new(t("表示するパケットがありません。")))
121
+ next
122
+ end
123
+ values = series.each_with_index.to_h { |item, index| [item.expression.empty? ? t("全件 %{index}", index: index + 1) : item.expression, item.points.map(&:packets)] }
124
+ omitted = series.first&.omitted_intervals || 0
125
+ content = Zaniah::Div.new.flex_col.gap(4)
126
+ content.child(Zaniah::UI::Label.new(t("先頭の%{count}区間を省略しています。間隔を広げると全期間を表示できます。", count: omitted), wrap: :word)) if omitted.positive?
127
+ content.child(Zaniah::UI::LineChart.new(values, width: 660, height: 220, label: t("区間ごとのパケット数")).test_id("vk.io.chart"))
128
+ analysis_content(content)
129
+ end
130
+ end
131
+ refresh_analysis
132
+ end
133
+
134
+ def follow_tcp_stream(stream_id = nil, format: :ascii, direction: nil)
135
+ return unless document
136
+ stream_id ||= selected_number && document.annotations[selected_number][:tcp_stream]
137
+ return show_error(Vanken::Error.new(t("TCPストリームを持つパケットを選択してください。"))) unless stream_id && stream_id >= 0
138
+ number = Zaniah::UI::NumberInput.new(stream_id.to_s, min: 0, label: t("ストリーム番号")).test_id("vk.follow.number")
139
+ formats = Zaniah::UI::Select.new([["ASCII", :ascii], [t("16進ダンプ"), :hex], [t("生データ(保存用)"), :raw]], value: format, label: t("表示形式"))
140
+ directions = Zaniah::UI::Select.new([[t("両方向"), nil], [t("クライアント → サーバー"), 0], [t("サーバー → クライアント"), 1]], value: direction, label: t("方向"))
141
+ reload = lambda do
142
+ value = number.number
143
+ raise ArgumentError, t("ストリーム番号は0以上の整数で指定してください。") unless value && value >= 0 && value == value.to_i
144
+ follow_tcp_stream(value.to_i, format: format, direction: direction)
145
+ rescue ArgumentError => error
146
+ show_error(error)
147
+ end
148
+ formats.on_change { |value, *_| format = value; reload.call }
149
+ directions.on_change { |value, *_| direction = value; reload.call }
150
+ search = Zaniah::UI::TextField.new("", label: t("文字列検索")).test_id("vk.follow.search")
151
+ controls = Zaniah::Div.new.flex_col.gap(6)
152
+ .child(Zaniah::Div.new.flex_row.gap(6).child(number).child(formats).child(directions)
153
+ .child(Zaniah::UI::Button.new(t("表示")).on_click { reload.call }))
154
+ .child(Zaniah::Div.new.flex_row.gap(6).child(search)
155
+ .child(Zaniah::UI::Button.new(t("次を検索")).on_click { search_stream(search.value) })
156
+ .child(Zaniah::UI::Button.new(t("保存")).test_id("vk.follow.save").on_click { save_stream_dialog(format: format, direction: direction) })
157
+ .child(Zaniah::UI::Button.new(t("このストリームを除外")).test_id("vk.follow.exclude").on_click do
158
+ set_filter("!(tcp.stream == #{stream_id})")
159
+ apply_filter
160
+ dismiss_dialog
161
+ end))
162
+ analysis_dialog(:follow_tcp_stream, t("TCP ストリームを追跡"), controls, reopen: reload)
163
+ @analysis_refresh = lambda do
164
+ analysis_background(->(doc, cancelled) { Gateway::Stream.new(doc, stream_id, cancelled: cancelled) }) do |stream|
165
+ @follow_stream = stream
166
+ chunks = stream.preview(format: format == :raw ? :hex : format, direction: direction)
167
+ runs = chunks.map { |chunk| {text: chunk.text, color: Zaniah::Color.parse(chunk.direction.zero? ? "#ce3e52" : "#277bcc")} }
168
+ @follow_text = Zaniah::UI::RichText.new(runs).test_id("vk.follow.text")
169
+ content = Zaniah::Div.new.flex_col.gap(6).child(Zaniah::UI::Label.new(stream.nodes.join(" ↔ "), wrap: :word))
170
+ if stream.byte_size(direction: direction) > Gateway::Stream::DISPLAY_LIMIT
171
+ content.child(Zaniah::UI::Label.new(t("表示は16 MiBまでです。保存は全量を含みます。"), wrap: :word))
172
+ end
173
+ content.child(Zaniah::ScrollView.new.h(280).child(@follow_text))
174
+ analysis_content(content)
175
+ end
176
+ end
177
+ refresh_analysis
178
+ rescue ArgumentError => error
179
+ show_error(error)
180
+ end
181
+
182
+ def search_stream(value)
183
+ return if value.empty? || !@follow_text
184
+ index = @follow_text.text.index(value, @follow_text.selection.head)
185
+ index ||= @follow_text.text.index(value)
186
+ return unless index
187
+ @follow_text.selection = Zaniah::TextSelection.new(index, index + value.bytesize)
188
+ @window.request_frame
189
+ end
190
+
191
+ def save_stream_dialog(format: :raw, direction: nil)
192
+ return unless @follow_stream
193
+ stream = @follow_stream
194
+ export_path("stream-#{stream.stream_id}.#{format == :raw ? 'bin' : 'txt'}") do |path|
195
+ analysis_background(->(_doc, cancelled) { stream.save(path, format: format, direction: direction, cancelled: cancelled) }) { dismiss_dialog }
196
+ end
197
+ end
198
+
199
+ def export_packets = export_dialog(false)
200
+ def export_dissections = export_dialog(true)
201
+
202
+ def export_dialog(dissections = false, scope: :all, format: nil, range_value: "", exclude_ignored: false)
203
+ return unless document
204
+ format ||= dissections ? :json : :pcapng
205
+ range = Zaniah::UI::TextField.new(range_value, label: t("範囲(1-10,20,30-)")).test_id("vk.export.range")
206
+ ignored = Zaniah::UI::Checkbox.new(t("無視したパケットを除外"), value: exclude_ignored)
207
+ scope_control = Zaniah::UI::Select.new([[t("全件"), :all], [t("表示中"), :displayed], [t("選択"), :selected], [t("マーク済み"), :marked], [t("最初と最後のマークの間"), :between_marks], [t("範囲指定"), :range]], value: scope, label: t("対象"))
208
+ .on_change { |value, *_| scope = value }
209
+ formats = dissections ? %i[json ndjson text csv] : %i[pcapng pcap]
210
+ content = Zaniah::Div.new.flex_col.gap(8).child(scope_control).child(range).child(ignored)
211
+ .child(Zaniah::UI::Select.new(formats, value: format, label: t("形式")).on_change { |value, *_| format = value })
212
+ .child(Zaniah::UI::Button.new(t("エクスポート")).test_id("vk.export.save").on_click do
213
+ numbers = Gateway::Exporter.new(document).numbers(scope: scope, range: range.value, selected: selected_number, exclude_ignored: ignored.value)
214
+ columns = @table.columns
215
+ columns = columns.map { |column| column[:key] == :time ? column.merge(format: @preferences.get("packet_list.time_format").to_sym) : column }
216
+ export_path("packets.#{format == :text ? 'txt' : format}") do |path|
217
+ analysis_background(->(doc, cancelled) { Gateway::Exporter.new(doc).write(path, format: format, numbers: numbers, columns: columns, cancelled: cancelled) }) { dismiss_dialog }
218
+ end
219
+ rescue StandardError => error
220
+ show_error(error)
221
+ end)
222
+ analysis_dialog(:export, t("%{kind}のエクスポート", kind: t(dissections ? "解析結果" : "パケット")), content, reopen: -> { export_dialog(dissections, scope: scope, format: format, range_value: range.value, exclude_ignored: ignored.value) })
223
+ end
224
+
225
+ def file_properties
226
+ return unless document
227
+ controls = Zaniah::UI::Button.new(t("SHA-256を計算")).test_id("vk.properties.sha256").disabled(!document.path || !File.file?(document.path))
228
+ .on_click do
229
+ analysis_background(->(doc, *) { Digest::SHA256.file(doc.path).hexdigest }) do |digest|
230
+ @analysis_results.child(Zaniah::UI::Label.new("SHA-256: #{digest}", wrap: :word))
231
+ @window.request_frame
232
+ end
233
+ end
234
+ analysis_dialog(:file_properties, t("キャプチャファイルのプロパティ"), controls, reopen: -> { file_properties })
235
+ @analysis_refresh = lambda do
236
+ analysis_background(->(doc, cancelled) { capture_properties(doc, cancelled) }) do |properties|
237
+ analysis_content(Zaniah::Div.new.flex_col.gap(4).children(properties.map { |label, value| Zaniah::UI::Label.new("#{label}: #{value}", wrap: :word) }))
238
+ end
239
+ end
240
+ refresh_analysis
241
+ end
242
+
243
+ private
244
+
245
+ def analysis_dialog(kind, title, controls, reopen: nil)
246
+ dismiss_dialog
247
+ @analysis_results = Zaniah::Div.new.flex_col.gap(4).child(Zaniah::UI::Label.new(t("集計中…")))
248
+ @dialog_kind = kind
249
+ @dialog_reopen = reopen
250
+ body = Zaniah::Div.new.flex_col.gap(8).child(controls).child(@analysis_results)
251
+ @dialog = Zaniah::UI::Dialog.new(body, title: title, width: [760, @window.content_size.width - 32].min, close_label: t("閉じる"))
252
+ .on_close { dismiss_dialog }.test_id("vk.#{kind}")
253
+ @window.request_frame
254
+ end
255
+
256
+ def analysis_background(operation, &publish)
257
+ return if @analysis_running || @closing || !document
258
+ doc, generation = document, @analysis_generation || 0
259
+ @analysis_running = true
260
+ @analysis_task = @app.executor.background do
261
+ cancelled = -> { @closing || generation != (@analysis_generation || 0) || !document.equal?(doc) }
262
+ next if cancelled.call
263
+ value = operation.call(doc, cancelled)
264
+ @app.executor.post do
265
+ next if cancelled.call
266
+ @analysis_running = false
267
+ publish.call(value)
268
+ end
269
+ rescue StandardError => error
270
+ @app.executor.post do
271
+ next if cancelled.call
272
+ @analysis_running = false
273
+ show_error(error)
274
+ end
275
+ end
276
+ end
277
+
278
+ def analysis_content(content)
279
+ @analysis_results.children.clear
280
+ @analysis_results.child(content)
281
+ @window.request_frame
282
+ end
283
+
284
+ def analysis_table(rows, keys, &selection)
285
+ labels = {severity: "重大度", code: "コード", protocol: "プロトコル", count: "件数", message: "メッセージ",
286
+ addr_a: "アドレス A", port_a: "ポート A", addr_b: "アドレス B", port_b: "ポート B", packets_ab: "パケット A → B", packets_ba: "パケット B → A", bytes_ab: "バイト A → B", bytes_ba: "バイト B → A", duration_ns: "期間(ns)",
287
+ address: "アドレス", port: "ポート", packets_tx: "送信パケット", packets_rx: "受信パケット", bytes_tx: "送信バイト", bytes_rx: "受信バイト"}
288
+ label = t({expert_info: "エキスパート情報", conversations: "会話", endpoints: "端点"}.fetch(@dialog_kind, "統計"))
289
+ source = AnalysisRows.new(rows.dup)
290
+ table = Zaniah::UI::VirtualTable.new(source, columns: keys.map { |key| {key: key, label: t(labels.fetch(key, key.to_s)), width: key == :message ? 400 : 130} }, height: 300, label: label)
291
+ .on_sort { |key, direction, _| source.sort(key, direction); table.invalidate }
292
+ table.on_select { |index, event, *_| selection.call(source[index], event) } if selection
293
+ table
294
+ end
295
+
296
+ def statistics_table(statistics)
297
+ rows = statistics.rows
298
+ columns = statistics.kind == :conv ? %i[addr_a port_a addr_b port_b packets_ab packets_ba bytes_ab bytes_ba duration_ns] : %i[address port packets_tx packets_rx bytes_tx bytes_rx]
299
+ selected = nil
300
+ table = analysis_table(rows, columns) { |row, _| selected = row }
301
+ content = Zaniah::Div.new.flex_col.gap(6)
302
+ .child(Zaniah::ScrollView.new(axis: :horizontal).h(300).child(table.w(columns.size * 130)))
303
+ content.child(Zaniah::UI::Label.new(t("メモリ上限により%{count}行が省略されました。", count: statistics.evicted), wrap: :word)) if statistics.evicted.positive?
304
+ content.child(Zaniah::Div.new.flex_row.gap(6)
305
+ .child(Zaniah::UI::Button.new(t("フィルタとして適用")).on_click do
306
+ next unless selected
307
+ set_filter(statistics_filter(statistics, selected))
308
+ apply_filter
309
+ dismiss_dialog
310
+ end)
311
+ .child(Zaniah::UI::Button.new(t("TCP ストリームを追跡")).disabled(statistics.type != :tcp).on_click do
312
+ next unless selected && statistics.type == :tcp
313
+ expression = statistics_filter(statistics, selected)
314
+ doc = document
315
+ program = Core::DisplayFilter.compile(expression, catalog: doc.catalog)
316
+ analysis_background(->(*) { (1..doc.count).find { |number| program.match?(doc.view(number)) } }) do |number|
317
+ follow_tcp_stream(doc.annotations[number][:tcp_stream]) if number
318
+ end
319
+ end))
320
+ content
321
+ end
322
+
323
+ def statistics_filter(table, row)
324
+ protocol = table.type.to_s
325
+ if table.kind == :endpoints
326
+ address = %i[tcp udp].include?(table.type) ? (row.address.include?(":") ? "ipv6" : "ip") : protocol
327
+ expression = "#{address}.addr == #{row.address}"
328
+ return %i[tcp udp].include?(table.type) ? "#{expression} && #{protocol}.port == #{row.port}" : expression
329
+ end
330
+ address = %i[tcp udp].include?(table.type) ? (row.addr_a.include?(":") ? "ipv6" : "ip") : protocol
331
+ forward = "#{address}.src == #{row.addr_a} && #{address}.dst == #{row.addr_b}"
332
+ backward = "#{address}.src == #{row.addr_b} && #{address}.dst == #{row.addr_a}"
333
+ if %i[tcp udp].include?(table.type)
334
+ forward += " && #{protocol}.srcport == #{row.port_a} && #{protocol}.dstport == #{row.port_b}"
335
+ backward += " && #{protocol}.srcport == #{row.port_b} && #{protocol}.dstport == #{row.port_a}"
336
+ end
337
+ "(#{forward}) || (#{backward})"
338
+ end
339
+
340
+ def export_path(default_name, &operation)
341
+ return path_dialog(t("エクスポート先"), &operation) if @backend == :tui
342
+ path = @window.prompt_for_paths(save: true, default_name: default_name)&.first
343
+ operation.call(path) if path
344
+ rescue StandardError => error
345
+ show_error(error)
346
+ end
347
+
348
+ def capture_properties(doc, cancelled)
349
+ count, bytes, captured, first, last = doc.count, 0, 0, nil, nil
350
+ interfaces = Hash.new(0)
351
+ (1..count).each do |number|
352
+ raise Vanken::Error, "operation cancelled" if cancelled.call
353
+ metadata = doc.store.metadata(number)
354
+ bytes += metadata[:original_length]
355
+ captured += metadata[:caplen]
356
+ first = [first || metadata[:timestamp_ns], metadata[:timestamp_ns]].min
357
+ last = [last || metadata[:timestamp_ns], metadata[:timestamp_ns]].max
358
+ interfaces[metadata[:interface]&.fetch("name", nil) || t("未記録")] += 1
359
+ end
360
+ duration = first && last ? (last - first) / 1e9 : 0
361
+ values = {t("ファイル") => doc.path || t("未保存"), t("パケット") => count, t("期間(秒)") => duration,
362
+ t("合計(bytes)") => bytes, t("保存済み(bytes)") => captured,
363
+ t("平均pps") => duration.positive? ? (count / duration).round(3) : 0,
364
+ t("平均bps") => duration.positive? ? (bytes * 8 / duration).round(3) : 0}
365
+ interfaces.each { |name, packets| values[t("インタフェース %{name}", name: name)] = t("%{count} パケット", count: packets) }
366
+ doc.capture_stats.each { |key, value| values[t("取得統計 %{key}", key: key)] = value }
367
+ values
368
+ end
369
+ end
370
+ end
371
+ end