sandcrawler 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/docs/findings.md +49 -0
- data/docs/releasing.md +28 -0
- data/docs/verification.md +80 -0
- data/exe/sandcrawler +5 -0
- data/lib/sandcrawler/acquisition/mounted_volume.rb +81 -0
- data/lib/sandcrawler/acquisition/picotool.rb +227 -0
- data/lib/sandcrawler/acquisition/serial_protocol.rb +136 -0
- data/lib/sandcrawler/acquisition/serial_shell.rb +162 -0
- data/lib/sandcrawler/acquisition/serial_templates.rb +63 -0
- data/lib/sandcrawler/cli/devices.rb +109 -0
- data/lib/sandcrawler/cli/offline.rb +199 -0
- data/lib/sandcrawler/cli.rb +149 -0
- data/lib/sandcrawler/errors.rb +11 -0
- data/lib/sandcrawler/extractor.rb +157 -0
- data/lib/sandcrawler/fat/allocation_table.rb +139 -0
- data/lib/sandcrawler/fat/boot_sector.rb +142 -0
- data/lib/sandcrawler/fat/dir_entry.rb +80 -0
- data/lib/sandcrawler/fat/lfn_assembler.rb +60 -0
- data/lib/sandcrawler/fat/volume.rb +198 -0
- data/lib/sandcrawler/fat/volume_locator.rb +108 -0
- data/lib/sandcrawler/image/raw_image.rb +38 -0
- data/lib/sandcrawler/image/slice.rb +25 -0
- data/lib/sandcrawler/image/source.rb +25 -0
- data/lib/sandcrawler/image/uf2_image.rb +83 -0
- data/lib/sandcrawler/log.rb +44 -0
- data/lib/sandcrawler/manifest.rb +37 -0
- data/lib/sandcrawler/path_sanitizer.rb +61 -0
- data/lib/sandcrawler/recovery/carver.rb +76 -0
- data/lib/sandcrawler/recovery/orphans.rb +52 -0
- data/lib/sandcrawler/recovery/undelete.rb +43 -0
- data/lib/sandcrawler/version.rb +5 -0
- data/lib/sandcrawler.rb +25 -0
- metadata +81 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "json"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Sandcrawler
|
|
10
|
+
module Acquisition
|
|
11
|
+
class Picotool
|
|
12
|
+
COMMANDS = %w[version info save].freeze
|
|
13
|
+
FLASH_BASE = 0x10000000
|
|
14
|
+
BLOCK_SIZE = 4096
|
|
15
|
+
MAX_OUTPUT_BYTES = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
def initialize(executable: nil, log: nil, timeout: 120)
|
|
18
|
+
unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
|
|
19
|
+
raise UsageError, "Picotool timeout must be finite and positive"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@executable = executable || ENV["PICOTOOL"] || "picotool"
|
|
23
|
+
@log = log
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(command, *arguments)
|
|
28
|
+
validate_command(command, arguments)
|
|
29
|
+
stdout, stderr, status = capture(command, arguments)
|
|
30
|
+
return stdout.b if status.success?
|
|
31
|
+
|
|
32
|
+
detail = [stdout.b, stderr.b].reject(&:empty?).join("\n").strip
|
|
33
|
+
missing = detail.match?(/no .*devices|device.*not found|unable to connect|no accessible/i)
|
|
34
|
+
error = missing ? DeviceNotFoundError : Error
|
|
35
|
+
raise error, "picotool #{command}: #{detail}"
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise DeviceNotFoundError, "picotool not found; install picotool 2.x or use --picotool PATH"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def version
|
|
41
|
+
run("version").strip
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def info
|
|
45
|
+
raw = run("info", "-a")
|
|
46
|
+
match = raw.match(/flash\s+size\s*:\s*(\d+(?:\.\d+)?)\s*([KMGT](?:i?B|bytes?)?|bytes?)?/i)
|
|
47
|
+
multiplier = match && { "K" => 1024, "M" => 1024**2, "G" => 1024**3, "T" => 1024**4 }.fetch(
|
|
48
|
+
match[2].to_s[0]&.upcase, 1
|
|
49
|
+
)
|
|
50
|
+
{ raw: raw, chip: raw[/\bRP(?:2040|2350)\b/i]&.upcase,
|
|
51
|
+
flash_size: match && (match[1].to_f * multiplier).to_i }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dump(out, passes: 2, force: false)
|
|
55
|
+
raise UsageError, "--passes must be an integer of at least 2" unless passes.is_a?(Integer) && passes >= 2
|
|
56
|
+
|
|
57
|
+
tool_version = version
|
|
58
|
+
device = info
|
|
59
|
+
output = Extractor.new(out, source: { type: "picotool", executable: @executable }, force: force, log: @log)
|
|
60
|
+
records = []
|
|
61
|
+
passes.times { records << save_pass(output, records.length + 1, device[:flash_size]) }
|
|
62
|
+
if records.map { |record| record[:sha256] }.uniq.length > 1
|
|
63
|
+
records << save_pass(output, records.length + 1, device[:flash_size])
|
|
64
|
+
end
|
|
65
|
+
validate_sizes!(records, device[:flash_size])
|
|
66
|
+
data, unstable = combine(output, records)
|
|
67
|
+
result = output.write(path: "dump.bin", data: data, category: nil, status: unstable.empty? ? "ok" : "unstable")
|
|
68
|
+
verify_output!(result)
|
|
69
|
+
File.chmod(0o444, File.join(output.out, result[:output]))
|
|
70
|
+
unless unstable.empty?
|
|
71
|
+
output.warning("UNSTABLE_BLOCKS",
|
|
72
|
+
"No strict majority for block offsets: #{unstable.join(', ')}")
|
|
73
|
+
end
|
|
74
|
+
metadata = {
|
|
75
|
+
tool: "sandcrawler", tool_version: VERSION, created_at: Time.now.iso8601,
|
|
76
|
+
picotool: { version: tool_version, info: device[:raw] },
|
|
77
|
+
device: device.except(:raw), passes: records,
|
|
78
|
+
result: { file: result[:output], sha256: result[:sha256], unstable_blocks: unstable }
|
|
79
|
+
}
|
|
80
|
+
metadata = Manifest.json_safe(metadata)
|
|
81
|
+
verify_output!(output.write(path: "dump.bin.sha256", data: "#{result[:sha256]} #{result[:output]}\n",
|
|
82
|
+
category: nil))
|
|
83
|
+
verify_output!(output.write(path: "dump.json", data: "#{JSON.pretty_generate(metadata)}\n", category: nil))
|
|
84
|
+
output.finish
|
|
85
|
+
metadata
|
|
86
|
+
rescue Error, IOError, SystemCallError => e
|
|
87
|
+
if output
|
|
88
|
+
output.warning("DUMP_FAILED", e.message)
|
|
89
|
+
output.finish
|
|
90
|
+
end
|
|
91
|
+
raise
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def capture(command, arguments)
|
|
97
|
+
Open3.popen3(@executable, command, *arguments) do |input, output, errors, process|
|
|
98
|
+
input.close
|
|
99
|
+
readers = [output, errors].map do |stream|
|
|
100
|
+
Thread.new do
|
|
101
|
+
text = stream.binmode.read(MAX_OUTPUT_BYTES + 1) || "".b
|
|
102
|
+
stop_process(process) if text.bytesize > MAX_OUTPUT_BYTES
|
|
103
|
+
text
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
unless process.join(@timeout)
|
|
107
|
+
stop_process(process)
|
|
108
|
+
raise IntegrityError, "picotool #{command} timed out after #{@timeout} seconds"
|
|
109
|
+
end
|
|
110
|
+
unless readers.all? { |reader| reader.join(1) }
|
|
111
|
+
raise IntegrityError, "picotool #{command} did not close its output streams"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
captured = readers.map(&:value)
|
|
115
|
+
if captured.any? { |text| text.bytesize > MAX_OUTPUT_BYTES }
|
|
116
|
+
raise IntegrityError, "picotool #{command} exceeded the 1 MiB output limit"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[*captured, process.value]
|
|
120
|
+
ensure
|
|
121
|
+
readers&.each { |reader| reader.kill if reader.alive? }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def stop_process(process)
|
|
126
|
+
Process.kill(Gem.win_platform? ? "KILL" : "TERM", process.pid)
|
|
127
|
+
Process.kill("KILL", process.pid) unless process.join(1)
|
|
128
|
+
rescue Errno::ESRCH
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_command(command, args)
|
|
133
|
+
raise SafetyError, "picotool command is not read-only: #{command}" unless COMMANDS.include?(command)
|
|
134
|
+
raise SafetyError, "Picotool arguments must be strings" unless args.all?(String)
|
|
135
|
+
|
|
136
|
+
allowed = case command
|
|
137
|
+
when "version" then args.empty?
|
|
138
|
+
when "info" then args.empty? || args == ["-a"]
|
|
139
|
+
when "save"
|
|
140
|
+
valid_all = args.size == 5 && args[0, 2] == %w[-a -v] && safe_filename?(args[2])
|
|
141
|
+
valid_range = args.size == 7 && args[0] == "-r" && args[3] == "-v" &&
|
|
142
|
+
args[1, 2].all? { |a| a.match?(/\A0x[0-9a-f]+\z/i) } && safe_filename?(args[4])
|
|
143
|
+
(valid_all || valid_range) && args[-2, 2] == %w[-t bin]
|
|
144
|
+
end
|
|
145
|
+
raise SafetyError, "Unsupported picotool arguments; reboot and write options are forbidden" unless allowed
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def safe_filename?(path)
|
|
149
|
+
path.is_a?(String) && !path.empty? && !path.start_with?("-") && !path.include?("\0")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def save_pass(output, number, flash_size)
|
|
153
|
+
Tempfile.create(["sandcrawler-pass", ".bin"]) do |temp|
|
|
154
|
+
path = temp.path
|
|
155
|
+
temp.close
|
|
156
|
+
begin
|
|
157
|
+
run("save", "-a", "-v", path, "-t", "bin")
|
|
158
|
+
rescue Error => e
|
|
159
|
+
preserve_failed_pass(output, path, number, "all", e)
|
|
160
|
+
raise if e.is_a?(DeviceNotFoundError) || !flash_size&.positive?
|
|
161
|
+
|
|
162
|
+
File.truncate(path, 0)
|
|
163
|
+
begin
|
|
164
|
+
run("save", "-r", format("0x%x", FLASH_BASE), format("0x%x", FLASH_BASE + flash_size), "-v", path, "-t",
|
|
165
|
+
"bin")
|
|
166
|
+
rescue Error => range_error
|
|
167
|
+
preserve_failed_pass(output, path, number, "range", range_error)
|
|
168
|
+
raise
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
record = File.open(path, "rb") do |file|
|
|
172
|
+
output.write(path: "pass#{number}.bin", data: file, category: nil)
|
|
173
|
+
end
|
|
174
|
+
verify_output!(record)
|
|
175
|
+
File.chmod(0o444, File.join(output.out, record[:output]))
|
|
176
|
+
{ file: record[:output], sha256: record[:sha256], size: record[:size] }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def preserve_failed_pass(output, path, number, mode, error)
|
|
181
|
+
output.warning("SAVE_FAILED", "Pass #{number} (#{mode}): #{error.message}")
|
|
182
|
+
return unless File.file?(path) && File.size(path).positive?
|
|
183
|
+
|
|
184
|
+
record = File.open(path, "rb") do |file|
|
|
185
|
+
output.write(path: "pass#{number}-#{mode}-failed.bin", data: file, category: nil,
|
|
186
|
+
status: "acquisition_error", error: error.message)
|
|
187
|
+
end
|
|
188
|
+
verify_output!(record)
|
|
189
|
+
File.chmod(0o444, File.join(output.out, record[:output]))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def verify_output!(record)
|
|
193
|
+
return if record[:output] && %w[ok unstable acquisition_error].include?(record[:status])
|
|
194
|
+
|
|
195
|
+
raise IntegrityError, "Could not preserve #{record[:path]}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_sizes!(records, expected)
|
|
199
|
+
sizes = records.map { |record| record[:size] }
|
|
200
|
+
return if sizes.uniq.size == 1 && sizes.first.positive? && (!expected || sizes.first == expected)
|
|
201
|
+
|
|
202
|
+
raise IntegrityError,
|
|
203
|
+
"Dump lengths #{sizes.inspect} do not match#{expected ? " flash size #{expected}" : ' each other'}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def combine(output, records)
|
|
207
|
+
images = records.map { |record| File.open(File.join(output.out, record[:file]), "rb") }
|
|
208
|
+
data = +"".b
|
|
209
|
+
unstable = []
|
|
210
|
+
(0...records.first[:size]).step(BLOCK_SIZE) do |offset|
|
|
211
|
+
blocks = images.map { |file| file.read(BLOCK_SIZE) }
|
|
212
|
+
expected = [BLOCK_SIZE, records.first[:size] - offset].min
|
|
213
|
+
unless blocks.all? { |block| block&.bytesize == expected }
|
|
214
|
+
raise IntegrityError, "Preserved pass length changed at offset #{offset}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
winner, count = blocks.tally.max_by { |_block, votes| votes }
|
|
218
|
+
unstable << offset unless count > records.length / 2
|
|
219
|
+
data << winner
|
|
220
|
+
end
|
|
221
|
+
[data, unstable]
|
|
222
|
+
ensure
|
|
223
|
+
images&.each(&:close)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "io/wait"
|
|
5
|
+
|
|
6
|
+
module Sandcrawler
|
|
7
|
+
module Acquisition
|
|
8
|
+
module SerialProtocol
|
|
9
|
+
MARKER = "@@SANDCRAWLER|"
|
|
10
|
+
ANSI = %r{\e\[[0-?]*[ -/]*[@-~]}
|
|
11
|
+
MAX_RESPONSE_BYTES = 40 * 1024 * 1024
|
|
12
|
+
MAX_RESPONSE_RECORDS = 65_537
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def decode_transfer(records)
|
|
17
|
+
data = +"".b
|
|
18
|
+
sequence = 0
|
|
19
|
+
ended = false
|
|
20
|
+
records.each do |record|
|
|
21
|
+
kind, first, second = record
|
|
22
|
+
unless record.size == 3 && %w[DATA END].include?(kind)
|
|
23
|
+
raise IntegrityError, "Unexpected serial transfer record"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if kind == "DATA"
|
|
27
|
+
unless !ended && first == sequence.to_s && second&.match?(/\A(?:[0-9a-f]{2}){1,256}\z/i)
|
|
28
|
+
raise IntegrityError, "Missing, duplicate or invalid serial chunk"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
data << [second].pack("H*")
|
|
32
|
+
sequence += 1
|
|
33
|
+
elsif kind == "END"
|
|
34
|
+
unless !ended && first == data.bytesize.to_s && second == (data.bytes.sum % 65_536).to_s
|
|
35
|
+
raise IntegrityError, "Serial byte count or checksum mismatch"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
ended = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
raise IntegrityError, "Serial END marker missing" unless ended
|
|
42
|
+
|
|
43
|
+
data
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def decode_listing(records)
|
|
47
|
+
ending = records.last
|
|
48
|
+
entries = records[0...-1]
|
|
49
|
+
unless ending&.size == 3 && ending[0] == "LISTEND" && ending[1] == entries.size.to_s &&
|
|
50
|
+
entries.all? { |record| record.size == 3 && record[0] == "LIST" }
|
|
51
|
+
raise IntegrityError, "Serial directory count or terminator mismatch"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
checksum = entries.sum { |record| record.drop(1).join("|").bytes.sum } % 65_536
|
|
55
|
+
raise IntegrityError, "Serial directory checksum mismatch" unless ending[2] == checksum.to_s
|
|
56
|
+
|
|
57
|
+
names = entries.map { |record| record[2] }
|
|
58
|
+
raise IntegrityError, "Duplicate serial directory entry" unless names.uniq.size == names.size
|
|
59
|
+
|
|
60
|
+
entries
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request(expected:)
|
|
64
|
+
marker = "#{MARKER}#{SecureRandom.hex(8)}|"
|
|
65
|
+
code = yield marker
|
|
66
|
+
error_marker = SerialTemplates.literal("#{marker}ERROR|")
|
|
67
|
+
done_marker = SerialTemplates.literal("#{marker}DONE")
|
|
68
|
+
@io.write("begin;#{code};rescue=>e;puts #{error_marker}+e.to_s;end;puts #{done_marker}\n")
|
|
69
|
+
records = []
|
|
70
|
+
response_bytes = 0
|
|
71
|
+
deadline = monotonic + @timeout
|
|
72
|
+
absolute_deadline = monotonic + 3600
|
|
73
|
+
loop do
|
|
74
|
+
line = read_line([deadline, absolute_deadline].min).gsub(ANSI, "").strip
|
|
75
|
+
next unless line.start_with?(marker)
|
|
76
|
+
|
|
77
|
+
record = line.delete_prefix(marker).split("|", -1)
|
|
78
|
+
break if record == ["DONE"]
|
|
79
|
+
|
|
80
|
+
allowed = [expected, "ERROR", { "DATA" => "END", "LIST" => "LISTEND" }[expected]]
|
|
81
|
+
raise IntegrityError, "Unexpected serial response record" unless allowed.include?(record[0])
|
|
82
|
+
|
|
83
|
+
response_bytes += line.bytesize
|
|
84
|
+
if response_bytes > MAX_RESPONSE_BYTES || records.size >= MAX_RESPONSE_RECORDS
|
|
85
|
+
raise IntegrityError, "Serial response exceeds the Pico transfer budget"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
records << record
|
|
89
|
+
deadline = monotonic + @timeout
|
|
90
|
+
end
|
|
91
|
+
error = records.find { |record| record[0] == "ERROR" }
|
|
92
|
+
raise Error, "R2P2: #{error.drop(1).join('|')}" if error
|
|
93
|
+
|
|
94
|
+
records
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def wait_prompt
|
|
98
|
+
deadline = monotonic + @timeout
|
|
99
|
+
loop do
|
|
100
|
+
if @buffer.gsub(ANSI, "").match?(/[>$#] ?\z/)
|
|
101
|
+
@buffer.clear
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
read_more(deadline)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_line(deadline)
|
|
109
|
+
loop do
|
|
110
|
+
index = @buffer.index("\n")
|
|
111
|
+
return @buffer.slice!(0, index + 1) if index
|
|
112
|
+
|
|
113
|
+
read_more(deadline)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_more(deadline)
|
|
118
|
+
remaining = deadline - monotonic
|
|
119
|
+
unless remaining.positive? && @io.to_io.wait_readable(remaining)
|
|
120
|
+
raise IntegrityError, "Serial response timed out"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
chunk = @io.read_nonblock(4096, exception: false)
|
|
124
|
+
raise IOError, "Serial connection closed" if chunk.nil?
|
|
125
|
+
return if chunk == :wait_readable
|
|
126
|
+
|
|
127
|
+
@buffer << chunk
|
|
128
|
+
raise FormatError, "Serial line exceeds 1 MiB" if @buffer.bytesize > 1_048_576
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def monotonic
|
|
132
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "serial_templates"
|
|
5
|
+
require_relative "serial_protocol"
|
|
6
|
+
|
|
7
|
+
module Sandcrawler
|
|
8
|
+
module Acquisition
|
|
9
|
+
class SerialShell
|
|
10
|
+
include SerialProtocol
|
|
11
|
+
|
|
12
|
+
def self.ports
|
|
13
|
+
if Gem.win_platform?
|
|
14
|
+
stdout, _stderr, status = Open3.capture3(
|
|
15
|
+
"powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
|
16
|
+
"[System.IO.Ports.SerialPort]::GetPortNames()"
|
|
17
|
+
)
|
|
18
|
+
return status.success? ? stdout.lines.map(&:strip).grep(/\ACOM\d+\z/i).uniq.sort : []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Dir.glob(%w[/dev/ttyACM* /dev/ttyUSB* /dev/cu.usbmodem* /dev/cu.usbserial*])
|
|
22
|
+
rescue Errno::ENOENT
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(port: nil, io: nil, baud: 115_200, timeout: 10, log: nil)
|
|
27
|
+
raise UsageError, "Serial timeout must be positive" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
28
|
+
raise UsageError, "Serial baud rate must be a positive integer" unless baud.is_a?(Integer) && baud.positive?
|
|
29
|
+
|
|
30
|
+
@port = port
|
|
31
|
+
@io = io
|
|
32
|
+
@baud = baud
|
|
33
|
+
@timeout = timeout
|
|
34
|
+
@log = log
|
|
35
|
+
@buffer = +"".b
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def copy(out, force: false, root: "/")
|
|
39
|
+
unless root.start_with?("/") && !root.include?("\0")
|
|
40
|
+
raise UsageError,
|
|
41
|
+
"Serial root must be an absolute path without NUL"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
output = Extractor.new(out, source: { type: "serial", port: @port, root: root }, force: force, log: @log)
|
|
45
|
+
connect
|
|
46
|
+
@io.write("\x03\n")
|
|
47
|
+
wait_prompt
|
|
48
|
+
@io.write("irb\n")
|
|
49
|
+
wait_prompt
|
|
50
|
+
@entered_irb = true
|
|
51
|
+
capabilities = probe(root)
|
|
52
|
+
output.warning("SERIAL_CAPABILITIES", capabilities.inspect)
|
|
53
|
+
copy_tree(output, root.sub(%r{/+\z}, ""), capabilities)
|
|
54
|
+
output.finish
|
|
55
|
+
rescue Error, SystemCallError, IOError => e
|
|
56
|
+
output&.warning("SERIAL_FAILED", e.message)
|
|
57
|
+
output&.finish
|
|
58
|
+
raise
|
|
59
|
+
ensure
|
|
60
|
+
begin
|
|
61
|
+
@io&.write("exit\n") if @entered_irb
|
|
62
|
+
rescue IOError, SystemCallError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
@io&.close if @owns_io
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def connect
|
|
71
|
+
return if @io
|
|
72
|
+
unless @port
|
|
73
|
+
raise UsageError,
|
|
74
|
+
"Select CDC0 with --port PORT (Windows: --port COM3). Candidates: #{self.class.ports.join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
require "serialport"
|
|
78
|
+
@io = SerialPort.new(@port, @baud, 8, 1, SerialPort::NONE)
|
|
79
|
+
@owns_io = true
|
|
80
|
+
rescue LoadError
|
|
81
|
+
raise UsageError, "Serial support needs the optional dependency: gem install serialport"
|
|
82
|
+
rescue Errno::ENOENT, Errno::ENODEV
|
|
83
|
+
raise DeviceNotFoundError, "Serial port not found: #{@port}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def probe(root)
|
|
87
|
+
records = request(expected: "CAP") { |marker| SerialTemplates.capabilities(root, marker) }
|
|
88
|
+
cap = records.find { |record| record[0] == "CAP" }
|
|
89
|
+
unless records.size == 1 && cap&.size == 4 && %w[entries
|
|
90
|
+
open].include?(cap[1]) && %w[directory
|
|
91
|
+
stat].include?(cap[2]) &&
|
|
92
|
+
%w[bytes each_byte].include?(cap[3])
|
|
93
|
+
raise FormatError, "R2P2 does not provide the required read-only file APIs"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
{ list: cap[1], directory: cap[2], bytes: cap[3] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def copy_tree(output, root, capabilities)
|
|
100
|
+
queue = [[root.empty? ? "/" : root, ""]]
|
|
101
|
+
visited = {}
|
|
102
|
+
until queue.empty?
|
|
103
|
+
remote, relative = queue.shift
|
|
104
|
+
next if visited[remote]
|
|
105
|
+
|
|
106
|
+
visited[remote] = true
|
|
107
|
+
begin
|
|
108
|
+
records = request(expected: "LIST") { |marker| SerialTemplates.listing(remote, capabilities, marker) }
|
|
109
|
+
decode_listing(records).each do |record|
|
|
110
|
+
name = decode_name(record[2])
|
|
111
|
+
path = [relative, name].reject(&:empty?).join("/")
|
|
112
|
+
full_path = "#{remote.sub(%r{/+\z}, '')}/#{name}"
|
|
113
|
+
if record[1] == "D"
|
|
114
|
+
queue << [full_path, path] if output.directory(path: path)
|
|
115
|
+
elsif record[1] == "F"
|
|
116
|
+
copy_file(output, full_path, path, capabilities)
|
|
117
|
+
else
|
|
118
|
+
raise FormatError, "Invalid serial entry type"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
rescue Error, IOError, SystemCallError => e
|
|
122
|
+
output.warning("LIST_FAILED", "#{remote}: #{e.message}")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def decode_name(hex)
|
|
128
|
+
raise FormatError, "Invalid filename hex" unless hex&.match?(/\A(?:[0-9a-f]{2})+\z/i)
|
|
129
|
+
|
|
130
|
+
name = [hex].pack("H*").force_encoding(Encoding::UTF_8)
|
|
131
|
+
unless name.valid_encoding? && !%w[. ..].include?(name) && !name.match?(%r{[/\\\x00]})
|
|
132
|
+
raise SafetyError, "Unsafe name in serial directory response"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
name
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def copy_file(output, remote, relative, capabilities)
|
|
139
|
+
attempts = 0
|
|
140
|
+
begin
|
|
141
|
+
attempts += 1
|
|
142
|
+
records = request(expected: "READCAP") { |marker| SerialTemplates.read_capability(remote, marker) }
|
|
143
|
+
cap = records.find { |record| record[0] == "READCAP" }
|
|
144
|
+
unless records.size == 1 && cap&.size == 2 && %w[read getc].include?(cap[1])
|
|
145
|
+
raise FormatError, "No supported file reader"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
records = request(expected: "DATA") do |marker|
|
|
149
|
+
SerialTemplates.transfer(remote, capabilities, cap[1], marker)
|
|
150
|
+
end
|
|
151
|
+
data = decode_transfer(records)
|
|
152
|
+
output.write(path: relative, data: data, original_name: File.basename(relative))
|
|
153
|
+
rescue Error, IOError, SystemCallError => e
|
|
154
|
+
retry if attempts < 3
|
|
155
|
+
|
|
156
|
+
output.warning("TRANSFER_FAILED", "#{remote}: #{e.message}")
|
|
157
|
+
output.write(path: relative, data: "".b, status: "transfer_error", error: e.message)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandcrawler
|
|
4
|
+
module Acquisition
|
|
5
|
+
module SerialTemplates
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def literal(path)
|
|
9
|
+
path.b.dump
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def capabilities(root, marker)
|
|
13
|
+
[
|
|
14
|
+
"r=#{literal(root)}",
|
|
15
|
+
'begin;Dir.entries(r);a="entries";rescue;Dir.open(r){|d|d.read};a="open";end',
|
|
16
|
+
'begin;File.directory?(r);b="directory";rescue;File.stat(r).directory?;b="stat";end',
|
|
17
|
+
'begin;"A".bytes.each{|x|x};c="bytes";rescue;"A".each_byte{|x|x};c="each_byte";end',
|
|
18
|
+
"puts #{literal("#{marker}CAP|")}+a+\"|\"+b+\"|\"+c"
|
|
19
|
+
].join(";")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def listing(path, capabilities, marker)
|
|
23
|
+
listing = if capabilities[:list] == "entries"
|
|
24
|
+
"names=Dir.entries(r)"
|
|
25
|
+
else
|
|
26
|
+
"names=[];Dir.open(r){|d|while n=d.read;names<<n;end}"
|
|
27
|
+
end
|
|
28
|
+
directory = if capabilities[:directory] == "directory"
|
|
29
|
+
'File.directory?(p+"/"+n)'
|
|
30
|
+
else
|
|
31
|
+
'File.stat(p+"/"+n).directory?'
|
|
32
|
+
end
|
|
33
|
+
prefix = path.sub(%r{/+\z}, "")
|
|
34
|
+
iterator = capabilities[:bytes] == "bytes" ? "record.bytes.each" : "record.each_byte"
|
|
35
|
+
"r=#{literal(path)};p=#{literal(prefix)};#{listing};count=0;sum=0;names.each{|n|if n!=\".\" && n!=\"..\";" \
|
|
36
|
+
"s=n;#{hex(capabilities)};record=(#{directory} ? \"D\" : \"F\")+\"|\"+h;" \
|
|
37
|
+
"#{iterator}{|b|sum=(sum+b)%65536};count+=1;puts #{literal("#{marker}LIST|")}+record;end};" \
|
|
38
|
+
"puts #{literal("#{marker}LISTEND|")}+count.to_s+\"|\"+sum.to_s"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read_capability(path, marker)
|
|
42
|
+
"f=File.open(#{literal(path)},\"r\");begin;begin;f.read(1);m=\"read\";rescue;f.getc;m=\"getc\";end;" \
|
|
43
|
+
"puts #{literal("#{marker}READCAP|")}+m;ensure;f.close;end"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def transfer(path, capabilities, read_mode, marker)
|
|
47
|
+
read = if read_mode == "read"
|
|
48
|
+
"s=f.read(256)"
|
|
49
|
+
else
|
|
50
|
+
's="";256.times{c=f.getc;break if c.nil?;s+=(c.is_a?(Integer) ? c.chr : c)}'
|
|
51
|
+
end
|
|
52
|
+
"f=File.open(#{literal(path)},\"r\");begin;i=0;n=0;sum=0;loop{#{read};break if s.nil? || s.empty?;" \
|
|
53
|
+
"#{hex(capabilities, checksum: true)};puts #{literal("#{marker}DATA|")}+i.to_s+\"|\"+h;" \
|
|
54
|
+
"i+=1};puts #{literal("#{marker}END|")}+n.to_s+\"|\"+sum.to_s;ensure;f.close;end"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hex(capabilities, checksum: false)
|
|
58
|
+
iterator = capabilities[:bytes] == "bytes" ? "s.bytes.each" : "s.each_byte"
|
|
59
|
+
"h=\"\";#{iterator}{|b|v=b.to_s(16);h+=(v.length==1 ? \"0\"+v : v)#{';n+=1;sum=(sum+b)%65536' if checksum}}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|