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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +157 -0
  5. data/docs/findings.md +49 -0
  6. data/docs/releasing.md +28 -0
  7. data/docs/verification.md +80 -0
  8. data/exe/sandcrawler +5 -0
  9. data/lib/sandcrawler/acquisition/mounted_volume.rb +81 -0
  10. data/lib/sandcrawler/acquisition/picotool.rb +227 -0
  11. data/lib/sandcrawler/acquisition/serial_protocol.rb +136 -0
  12. data/lib/sandcrawler/acquisition/serial_shell.rb +162 -0
  13. data/lib/sandcrawler/acquisition/serial_templates.rb +63 -0
  14. data/lib/sandcrawler/cli/devices.rb +109 -0
  15. data/lib/sandcrawler/cli/offline.rb +199 -0
  16. data/lib/sandcrawler/cli.rb +149 -0
  17. data/lib/sandcrawler/errors.rb +11 -0
  18. data/lib/sandcrawler/extractor.rb +157 -0
  19. data/lib/sandcrawler/fat/allocation_table.rb +139 -0
  20. data/lib/sandcrawler/fat/boot_sector.rb +142 -0
  21. data/lib/sandcrawler/fat/dir_entry.rb +80 -0
  22. data/lib/sandcrawler/fat/lfn_assembler.rb +60 -0
  23. data/lib/sandcrawler/fat/volume.rb +198 -0
  24. data/lib/sandcrawler/fat/volume_locator.rb +108 -0
  25. data/lib/sandcrawler/image/raw_image.rb +38 -0
  26. data/lib/sandcrawler/image/slice.rb +25 -0
  27. data/lib/sandcrawler/image/source.rb +25 -0
  28. data/lib/sandcrawler/image/uf2_image.rb +83 -0
  29. data/lib/sandcrawler/log.rb +44 -0
  30. data/lib/sandcrawler/manifest.rb +37 -0
  31. data/lib/sandcrawler/path_sanitizer.rb +61 -0
  32. data/lib/sandcrawler/recovery/carver.rb +76 -0
  33. data/lib/sandcrawler/recovery/orphans.rb +52 -0
  34. data/lib/sandcrawler/recovery/undelete.rb +43 -0
  35. data/lib/sandcrawler/version.rb +5 -0
  36. data/lib/sandcrawler.rb +25 -0
  37. 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