Skip to content

Commit e698ae8

Browse files
Preserve and sanitise imported heartbeat strings
Co-authored-by: Amp <amp@ampcode.com>
1 parent 01420e5 commit e698ae8

3 files changed

Lines changed: 57 additions & 31 deletions

File tree

app/services/heartbeat_import_service.rb

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ def self.import_from_file(file_content, user, on_progress: nil, progress_interva
1717
heartbeat_batch.clear
1818
end
1919

20-
handler = HeartbeatSaxHandler.new do |hb|
20+
handler = HeartbeatStreamHandler.new do |hb|
2121
total_count += 1
2222
on_progress&.call(total_count) if progress_interval.positive? && (total_count % progress_interval).zero?
2323

2424
heartbeat_batch << hb
2525
flush.call if heartbeat_batch.size >= BATCH_SIZE
2626
end
2727

28-
Oj.saj_parse(handler, file_content)
28+
Oj.sc_parse(handler, file_content)
2929
on_progress&.call(total_count)
3030

3131
raise StandardError, "Expected a heartbeat export JSON file." if total_count.zero?
@@ -40,54 +40,56 @@ def self.import_from_file(file_content, user, on_progress: nil, progress_interva
4040
skipped_count: total_count - imported_count, errors: errors + [ e.message ] }
4141
end
4242

43-
class HeartbeatSaxHandler < Oj::Saj
43+
# Retain only the current heartbeat, not the surrounding dump or day arrays.
44+
# Unlike SAJ, ScHandler preserves embedded NULs for ingestion to sanitize.
45+
class HeartbeatStreamHandler < Oj::ScHandler
4446
def initialize(&block)
4547
@block = block
4648
@depth = 0
47-
@current_heartbeat = nil
4849
@heartbeat_array_depths = []
49-
@field_array_stack = []
5050
end
5151

52-
def hash_start(key)
53-
@current_heartbeat = {} if inside_heartbeat_array? && @depth == @heartbeat_array_depths.last + 1
52+
def hash_start
53+
@key = nil
5454
@depth += 1
55+
{} if @heartbeat_array_depths.any?
5556
end
5657

57-
def hash_end(key)
58+
def hash_end
5859
@depth -= 1
59-
if inside_heartbeat_array? && @depth == @heartbeat_array_depths.last + 1 && @current_heartbeat
60-
@block.call(@current_heartbeat)
61-
@current_heartbeat = nil
62-
end
6360
end
6461

65-
def array_start(key)
66-
@heartbeat_array_depths << @depth if key == "heartbeats"
67-
if @current_heartbeat && key.present?
68-
@current_heartbeat[key] = []
69-
@field_array_stack << key
62+
def hash_key(key)
63+
@key = key
64+
end
65+
66+
def hash_set(hash, key, value)
67+
hash[key] = value if hash
68+
end
69+
70+
def array_start
71+
container = if @key == "heartbeats" && @heartbeat_array_depths.empty?
72+
@heartbeat_array_depths << @depth
73+
:heartbeats
74+
elsif @heartbeat_array_depths.any?
75+
[]
7076
end
77+
@key = nil
7178
@depth += 1
79+
container
7280
end
7381

74-
def array_end(key)
82+
def array_end
7583
@depth -= 1
76-
@heartbeat_array_depths.pop if key == "heartbeats" && @heartbeat_array_depths.last == @depth
77-
@field_array_stack.pop if @field_array_stack.last == key
84+
@heartbeat_array_depths.pop if @heartbeat_array_depths.last == @depth
7885
end
7986

80-
def add_value(value, key)
81-
return unless @current_heartbeat
82-
if key
83-
@current_heartbeat[key] = value
84-
elsif @field_array_stack.any?
85-
@current_heartbeat[@field_array_stack.last] << value
87+
def array_append(array, value)
88+
if array == :heartbeats
89+
@block.call(value)
90+
elsif array
91+
array << value
8692
end
8793
end
88-
89-
private
90-
91-
def inside_heartbeat_array? = @heartbeat_array_depths.any?
9294
end
9395
end

app/services/heartbeat_ingest.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ def ingest_import
237237

238238
def normalize_imported_heartbeat(heartbeat, placeholder_state: { contexts: {}, last_project: nil })
239239
hb = heartbeat.respond_to?(:with_indifferent_access) ? heartbeat.with_indifferent_access : heartbeat.to_h.with_indifferent_access
240-
user_agent_info = (@user_agents_by_id[hb[:user_agent_id].to_s] || {}).with_indifferent_access
240+
hb = strip_null_bytes(hb)
241+
user_agent_info = strip_null_bytes((@user_agents_by_id[hb[:user_agent_id].to_s] || {}).with_indifferent_access)
241242
resolved_user_agent = hb[:user_agent].presence || user_agent_info[:value].presence || hb[:user_agent_id].presence
242243
parsed_user_agent = parse_user_agent(resolved_user_agent, category: hb[:category])
243244
derived_ai_editor = parsed_user_agent[:editor].presence if parsed_user_agent[:ai_model].present?

test/services/heartbeat_import_service_test.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
require "test_helper"
22

33
class HeartbeatImportServiceTest < ActiveSupport::TestCase
4+
test "sanitizes null bytes without losing valid batch rows and deduplicates replays" do
5+
user = create(:user)
6+
rows = [
7+
{ entity: "first.rb", project: "api", time: 1_700_000_000.0, type: "file" },
8+
{ entity: "sec\0ond.rb", project: "api", branch: "ma\0in", dependencies: [ "ra\0ils" ], time: 1_700_000_060.0, type: "file" }
9+
]
10+
11+
result = HeartbeatImportService.import_from_file(StringIO.new({ heartbeats: rows }.to_json), user)
12+
assert result[:success], result[:error]
13+
assert_equal 2, result[:imported_count]
14+
heartbeat = user.heartbeats.find_by!(entity: "second.rb")
15+
assert_equal "main", heartbeat.branch
16+
assert_equal [ "rails" ], heartbeat.dependencies
17+
18+
sanitized = [ rows.first, rows.second.merge(entity: "second.rb", branch: "main", dependencies: [ "rails" ]) ]
19+
[ rows, sanitized ].each do |replay|
20+
result = HeartbeatImportService.import_from_file({ heartbeats: replay }.to_json, user)
21+
assert result[:success], result[:error]
22+
assert_equal 0, result[:imported_count]
23+
end
24+
assert_equal 2, user.heartbeats.count
25+
end
26+
427
test "deduplicates imported heartbeats by fields hash" do
528
user = create(:user)
629
file_content = {

0 commit comments

Comments
 (0)