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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "volume"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module FAT
|
|
7
|
+
class VolumeLocator
|
|
8
|
+
Candidate = Struct.new(:offset, :boot_sector, :score, :score_details, :boot_sector_offset, keyword_init: true) do
|
|
9
|
+
def to_h
|
|
10
|
+
boot_sector.to_h.merge(offset: offset, size: boot_sector.byte_size, score: score,
|
|
11
|
+
score_details: score_details, boot_sector_offset: boot_sector_offset || offset,
|
|
12
|
+
warnings: warnings)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def warnings
|
|
16
|
+
return [] unless boot_sector_offset && boot_sector_offset != offset
|
|
17
|
+
|
|
18
|
+
[{ code: "BACKUP_BOOT_SECTOR",
|
|
19
|
+
detail: "Boot sector at #{boot_sector_offset} implies volume at #{offset}; " \
|
|
20
|
+
"FAT copies match and the root entry is plausible" }]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(source)
|
|
25
|
+
@source = source
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def candidates
|
|
29
|
+
@candidates ||= begin
|
|
30
|
+
found = {}
|
|
31
|
+
(0..(@source.size - 512)).step(512) do |offset|
|
|
32
|
+
bytes = @source.read_at(offset, 512)
|
|
33
|
+
candidate = inspect_boot(offset, bytes)
|
|
34
|
+
backup = inspect_backup(offset, bytes, candidate)
|
|
35
|
+
if backup
|
|
36
|
+
found[backup.offset] ||= backup
|
|
37
|
+
elsif candidate
|
|
38
|
+
found[offset] = candidate
|
|
39
|
+
end
|
|
40
|
+
next if candidate || backup || bytes.byteslice(510, 2) != "\x55\xAA".b
|
|
41
|
+
|
|
42
|
+
mbr_offsets(bytes).each do |partition_offset|
|
|
43
|
+
next if found.key?(partition_offset) || partition_offset + 512 > @source.size
|
|
44
|
+
|
|
45
|
+
candidate = inspect_boot(partition_offset, @source.read_at(partition_offset, 512))
|
|
46
|
+
found[partition_offset] = candidate if candidate
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
found.values.sort_by { |candidate| [-candidate.score, candidate.offset] }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def inspect_backup(offset, bytes, candidate)
|
|
56
|
+
return if candidate&.score_details&.key?(:media)
|
|
57
|
+
|
|
58
|
+
boot = candidate&.boot_sector || BootSector.new(bytes)
|
|
59
|
+
return unless boot.fat_type == "FAT32" && boot.backup_sector.between?(1, boot.reserved_sectors - 1)
|
|
60
|
+
|
|
61
|
+
base = offset - (boot.backup_sector * boot.bytes_per_sector)
|
|
62
|
+
return if base.negative?
|
|
63
|
+
|
|
64
|
+
recovered = inspect_boot(base, bytes)
|
|
65
|
+
return unless recovered && %i[media fat_copies root_entry].all? { |key| recovered.score_details.key?(key) }
|
|
66
|
+
|
|
67
|
+
recovered.boot_sector_offset = offset
|
|
68
|
+
recovered
|
|
69
|
+
rescue FormatError, OutOfRangeError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect_boot(offset, bytes)
|
|
74
|
+
boot = BootSector.new(bytes, available_size: @source.size - offset)
|
|
75
|
+
details = {}
|
|
76
|
+
fat_offset = offset + (boot.reserved_sectors * boot.bytes_per_sector)
|
|
77
|
+
details[:media] = 2 if @source.read_at(fat_offset, 1).getbyte(0) == boot.media
|
|
78
|
+
if boot.fat_count == 2
|
|
79
|
+
size = boot.fat_sectors * boot.bytes_per_sector
|
|
80
|
+
details[:fat_copies] = 2 if @source.read_at(fat_offset, size) == @source.read_at(fat_offset + size, size)
|
|
81
|
+
end
|
|
82
|
+
root = if boot.root_cluster
|
|
83
|
+
offset + boot.cluster_offset(boot.root_cluster)
|
|
84
|
+
else
|
|
85
|
+
offset + ((boot.reserved_sectors + (boot.fat_count * boot.fat_sectors)) * boot.bytes_per_sector)
|
|
86
|
+
end
|
|
87
|
+
entry = @source.read_at(root, 32)
|
|
88
|
+
if entry.getbyte(0).positive? && entry.getbyte(0) != 255 && (entry.getbyte(11) & 0xC0).zero?
|
|
89
|
+
details[:root_entry] = 2
|
|
90
|
+
end
|
|
91
|
+
details[:r2p2_label] = 3 if boot.label.include?("R2P2")
|
|
92
|
+
Candidate.new(offset: offset, boot_sector: boot, score: details.values.sum, score_details: details)
|
|
93
|
+
rescue FormatError, OutOfRangeError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def mbr_offsets(bytes)
|
|
98
|
+
4.times.flat_map do |index|
|
|
99
|
+
entry = bytes.byteslice(446 + (index * 16), 16)
|
|
100
|
+
next [] unless [0x01, 0x04, 0x06, 0x0B, 0x0C, 0x0E].include?(entry.getbyte(4))
|
|
101
|
+
|
|
102
|
+
start = entry.byteslice(8, 4).unpack1("V")
|
|
103
|
+
start.positive? ? [start * 512, start * 4096] : []
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "source"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module Image
|
|
7
|
+
class RawImage < Source
|
|
8
|
+
def initialize(path)
|
|
9
|
+
@io = File.open(path, "rb")
|
|
10
|
+
@size = @io.size
|
|
11
|
+
@lock = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read_at(offset, length)
|
|
15
|
+
check_range(offset, length)
|
|
16
|
+
return "".b if length.zero?
|
|
17
|
+
|
|
18
|
+
data = begin
|
|
19
|
+
@io.pread(length, offset)
|
|
20
|
+
rescue NoMethodError, NotImplementedError, Errno::ENOSYS
|
|
21
|
+
@lock.synchronize do
|
|
22
|
+
@io.seek(offset)
|
|
23
|
+
@io.read(length)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
raise OutOfRangeError, "Image changed or ended during read" unless data&.bytesize == length
|
|
27
|
+
|
|
28
|
+
data.b
|
|
29
|
+
rescue EOFError
|
|
30
|
+
raise OutOfRangeError, "Image ended during read"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def close
|
|
34
|
+
@io.close unless @io.closed?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "source"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module Image
|
|
7
|
+
class Slice < Source
|
|
8
|
+
def initialize(source, offset, length)
|
|
9
|
+
unless offset.is_a?(Integer) && length.is_a?(Integer) &&
|
|
10
|
+
offset >= 0 && length >= 0 && offset + length <= source.size
|
|
11
|
+
raise OutOfRangeError, "Slice outside image"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
@source = source
|
|
15
|
+
@offset = offset
|
|
16
|
+
@size = length
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read_at(offset, length)
|
|
20
|
+
check_range(offset, length)
|
|
21
|
+
@source.read_at(@offset + offset, length)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandcrawler
|
|
4
|
+
module Image
|
|
5
|
+
def self.open(path)
|
|
6
|
+
File.extname(path).downcase == ".uf2" ? Uf2Image.new(path) : RawImage.new(path)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Source
|
|
10
|
+
attr_reader :size
|
|
11
|
+
|
|
12
|
+
def check_range(offset, length)
|
|
13
|
+
unless offset.is_a?(Integer) && length.is_a?(Integer) && offset >= 0 && length >= 0 && offset + length <= size
|
|
14
|
+
raise OutOfRangeError, "Read outside image: offset=#{offset}, length=#{length}, size=#{size}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def close; end
|
|
19
|
+
|
|
20
|
+
def warnings
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "source"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module Image
|
|
7
|
+
class Uf2Image < Source
|
|
8
|
+
FLASH_BASE = 0x10000000
|
|
9
|
+
FLASH_SIZE = 16 * 1024 * 1024
|
|
10
|
+
attr_reader :warnings
|
|
11
|
+
|
|
12
|
+
def initialize(path)
|
|
13
|
+
@blocks = []
|
|
14
|
+
@warnings = []
|
|
15
|
+
@numbers = {}
|
|
16
|
+
File.open(path, "rb") do |io|
|
|
17
|
+
raise FormatError, "UF2 size must be a multiple of 512" unless io.size.positive? && (io.size % 512).zero?
|
|
18
|
+
|
|
19
|
+
parse_block(io.read(512)) until io.eof?
|
|
20
|
+
end
|
|
21
|
+
raise FormatError, "UF2 has no flash payload" if @blocks.empty?
|
|
22
|
+
|
|
23
|
+
@blocks.sort_by!(&:first)
|
|
24
|
+
@blocks.each_cons(2) do |left, right|
|
|
25
|
+
overlap = left[0] + left[1].bytesize - right[0]
|
|
26
|
+
next unless overlap.positive?
|
|
27
|
+
raise FormatError, "Overlapping UF2 flash payloads" unless left == right
|
|
28
|
+
end
|
|
29
|
+
@blocks.uniq!
|
|
30
|
+
if @numbers.size < @total
|
|
31
|
+
@warnings << { code: "UF2_INCOMPLETE", detail: "UF2 contains #{@numbers.size} of #{@total} declared blocks" }
|
|
32
|
+
end
|
|
33
|
+
@size = @blocks.map { |offset, bytes| offset + bytes.bytesize }.max
|
|
34
|
+
return unless @blocks.sum { |_, bytes| bytes.bytesize } != size
|
|
35
|
+
|
|
36
|
+
@warnings << { code: "UF2_GAPS",
|
|
37
|
+
detail: "Missing UF2 ranges are filled with 0xFF; they are not verified erased flash" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read_at(offset, length)
|
|
41
|
+
check_range(offset, length)
|
|
42
|
+
result = "\xFF".b * length
|
|
43
|
+
index = @blocks.bsearch_index { |start, bytes| start + bytes.bytesize > offset } || @blocks.length
|
|
44
|
+
while index < @blocks.length
|
|
45
|
+
start, bytes = @blocks[index]
|
|
46
|
+
break if start >= offset + length
|
|
47
|
+
|
|
48
|
+
first = [start, offset].max
|
|
49
|
+
last = [start + bytes.bytesize, offset + length].min
|
|
50
|
+
result[first - offset, last - first] = bytes.byteslice(first - start, last - first) if last > first
|
|
51
|
+
index += 1
|
|
52
|
+
end
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_block(block)
|
|
59
|
+
raise FormatError, "UF2 block was truncated during read" unless block&.bytesize == 512
|
|
60
|
+
|
|
61
|
+
magic1, magic2, flags, address, length, number, total = block.unpack("V7")
|
|
62
|
+
unless magic1 == 0x0A324655 && magic2 == 0x9E5D5157 && block[508, 4].unpack1("V") == 0x0AB16F30
|
|
63
|
+
raise FormatError, "Invalid UF2 magic"
|
|
64
|
+
end
|
|
65
|
+
raise FormatError, "UF2 file containers are unsupported" unless (flags & 0x1000).zero?
|
|
66
|
+
unless length.between?(1, 476) && total.positive? && number < total
|
|
67
|
+
raise FormatError, "Invalid UF2 payload or block number"
|
|
68
|
+
end
|
|
69
|
+
raise FormatError, "Inconsistent UF2 block count" if @total && total != @total
|
|
70
|
+
raise FormatError, "Conflicting UF2 block number #{number}" if @numbers[number] && @numbers[number] != block
|
|
71
|
+
|
|
72
|
+
@total = total
|
|
73
|
+
@numbers[number] = block
|
|
74
|
+
return unless (flags & 1).zero?
|
|
75
|
+
|
|
76
|
+
offset = address - FLASH_BASE
|
|
77
|
+
raise FormatError, "UF2 payload outside Pico XIP flash" unless offset >= 0 && offset + length <= FLASH_SIZE
|
|
78
|
+
|
|
79
|
+
@blocks << [offset, block.byteslice(32, length)]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandcrawler
|
|
4
|
+
class Log
|
|
5
|
+
attr_reader :lines
|
|
6
|
+
|
|
7
|
+
def self.escape(message)
|
|
8
|
+
message.to_s.b.force_encoding(Encoding::UTF_8).scrub.gsub(/[\u0000-\u001f\u007f-\u009f]/) do |character|
|
|
9
|
+
format("\\u%04x", character.ord)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(io: $stderr, verbose: false, quiet: false)
|
|
14
|
+
@io = io
|
|
15
|
+
@verbose = verbose
|
|
16
|
+
@quiet = quiet
|
|
17
|
+
@lines = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def info(message)
|
|
21
|
+
record("INFO", message, !@quiet)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def warn(message)
|
|
25
|
+
record("WARN", message, true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def error(message)
|
|
29
|
+
record("ERROR", message, true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def debug(message)
|
|
33
|
+
record("DEBUG", message, @verbose && !@quiet)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def record(level, message, display)
|
|
39
|
+
line = "#{level}: #{self.class.escape(message)}"
|
|
40
|
+
@lines << line
|
|
41
|
+
@io.puts(line) if display
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
class Manifest
|
|
7
|
+
attr_reader :data
|
|
8
|
+
|
|
9
|
+
def initialize(source:, volume: nil)
|
|
10
|
+
@data = { tool: "sandcrawler", tool_version: VERSION, created_at: Time.now.iso8601,
|
|
11
|
+
source: source, volume: volume, files: [], directories: [], warnings: [] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.json_safe(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.each_with_object({}) do |(key, item), result|
|
|
18
|
+
result[key] = json_safe(item)
|
|
19
|
+
next unless item.is_a?(String) && item.b != result[key].b
|
|
20
|
+
|
|
21
|
+
hex_key = key.is_a?(Symbol) ? :"#{key}_hex" : "#{key}_hex"
|
|
22
|
+
result[hex_key] = item.b.unpack1("H*")
|
|
23
|
+
end
|
|
24
|
+
when Array then value.map { |item| json_safe(item) }
|
|
25
|
+
when String then value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def warning(code, detail)
|
|
31
|
+
item = { code: code.to_s.dup.force_encoding(Encoding::UTF_8).scrub,
|
|
32
|
+
detail: detail.to_s.dup.force_encoding(Encoding::UTF_8).scrub }
|
|
33
|
+
@data[:warnings] << item unless @data[:warnings].include?(item)
|
|
34
|
+
item
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
class PathSanitizer
|
|
7
|
+
RESERVED = /\A(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|\z)/i
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@directories = {}
|
|
11
|
+
@used = Hash.new { |hash, key| hash[key] = {} }
|
|
12
|
+
%w[manifest.json sandcrawler.log].each { |name| @used[""][name] = true }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.component(name)
|
|
16
|
+
text = name.to_s.b.force_encoding(Encoding::UTF_8).scrub("_")
|
|
17
|
+
text = text.gsub(%r{[\x00-\x1f\x7f<>:"/\\|?*]}, "_").sub(/[. ]+\z/, "")
|
|
18
|
+
text = "_" if text.empty? || %w[. ..].include?(text)
|
|
19
|
+
text = "_#{text}" if RESERVED.match?(text)
|
|
20
|
+
if text.bytesize > 180
|
|
21
|
+
suffix = Digest::SHA256.hexdigest(text)[0, 12]
|
|
22
|
+
text = text.byteslice(0, 160).scrub("") + "~#{suffix}"
|
|
23
|
+
end
|
|
24
|
+
text
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def path(original, directory: false, namespace: nil)
|
|
28
|
+
text = original.to_s.b
|
|
29
|
+
parts = text.tr("\\", "/").sub(%r{\A[A-Za-z]:/}, "").split("/")
|
|
30
|
+
parts.reject! { |part| part.empty? || %w[. ..].include?(part) }
|
|
31
|
+
parts = ["_"] if parts.empty?
|
|
32
|
+
parts.unshift(namespace) if namespace
|
|
33
|
+
parent = ""
|
|
34
|
+
parts.each_with_index do |part, index|
|
|
35
|
+
is_directory = index < parts.size - 1 || directory
|
|
36
|
+
key = [parent, part]
|
|
37
|
+
name = is_directory && @directories[key]
|
|
38
|
+
unless name
|
|
39
|
+
name = available_name(parent, self.class.component(part))
|
|
40
|
+
@directories[key] = name if is_directory
|
|
41
|
+
end
|
|
42
|
+
parent = parent.empty? ? name : "#{parent}/#{name}"
|
|
43
|
+
end
|
|
44
|
+
parent
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def available_name(parent, name)
|
|
50
|
+
candidate = name
|
|
51
|
+
number = 1
|
|
52
|
+
while @used[parent][candidate.unicode_normalize(:nfc).downcase]
|
|
53
|
+
number += 1
|
|
54
|
+
extension = File.extname(name)
|
|
55
|
+
candidate = "#{File.basename(name, extension)}~#{number}#{extension}"
|
|
56
|
+
end
|
|
57
|
+
@used[parent][candidate.unicode_normalize(:nfc).downcase] = true
|
|
58
|
+
candidate
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../fat/volume"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module Recovery
|
|
7
|
+
class Carver
|
|
8
|
+
def initialize(source, block_size: 4096, offset: 0, length: nil)
|
|
9
|
+
@source = source
|
|
10
|
+
@block_size = block_size
|
|
11
|
+
@offset = offset
|
|
12
|
+
@length = length || (source.size - offset)
|
|
13
|
+
unless block_size.is_a?(Integer) && block_size.positive? &&
|
|
14
|
+
offset >= 0 && @length >= 0 && offset + @length <= source.size
|
|
15
|
+
raise UsageError, "Invalid carving range or block size"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def classify(bytes)
|
|
20
|
+
return "zero" if bytes.empty? || bytes.bytes.all?(&:zero?)
|
|
21
|
+
return "erased" if bytes.bytes.all? { |byte| byte == 255 }
|
|
22
|
+
|
|
23
|
+
text = bytes.dup.force_encoding("UTF-8")
|
|
24
|
+
return "binary" unless text.valid_encoding?
|
|
25
|
+
|
|
26
|
+
printable = text.each_char.count { |char| char.match?(/[[:print:]\t\r\n]/) }
|
|
27
|
+
printable.to_f / text.length >= 0.95 ? "text" : "binary"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each
|
|
31
|
+
return enum_for(__method__) unless block_given?
|
|
32
|
+
|
|
33
|
+
data = "".b
|
|
34
|
+
start = nil
|
|
35
|
+
(@offset...(@offset + @length)).step(@block_size) do |offset|
|
|
36
|
+
length = [@block_size, @offset + @length - offset].min
|
|
37
|
+
begin
|
|
38
|
+
bytes = @source.read_at(offset, length)
|
|
39
|
+
rescue Error, IOError, SystemCallError => e
|
|
40
|
+
yield fragment(start, data) if start
|
|
41
|
+
yield unreadable_fragment(offset, length, e)
|
|
42
|
+
start = nil
|
|
43
|
+
data = "".b
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
if classify(bytes) == "text"
|
|
47
|
+
start ||= offset
|
|
48
|
+
data << bytes
|
|
49
|
+
elsif start
|
|
50
|
+
yield fragment(start, data)
|
|
51
|
+
start = nil
|
|
52
|
+
data = "".b
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
yield fragment(start, data) if start
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def unreadable_fragment(offset, length, error)
|
|
61
|
+
entry = FAT::DirEntry.recovered(path: format("%08x.txt", offset), size: length)
|
|
62
|
+
warnings = [{ code: "READ_ERROR", detail: "Source offset #{offset}, length #{length}: #{error.message}" }]
|
|
63
|
+
result = FAT::Volume::ReadResult.new(data: "".b, status: "read_error", warnings: warnings)
|
|
64
|
+
[entry, result]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fragment(offset, data)
|
|
68
|
+
entry = FAT::DirEntry.recovered(path: format("%08x.txt", offset), size: data.bytesize)
|
|
69
|
+
warnings = [{ code: "CARVED_TEXT",
|
|
70
|
+
detail: "Text fragment at source offset #{offset}; original boundaries are unknown" }]
|
|
71
|
+
result = FAT::Volume::ReadResult.new(data: data, status: "carved", warnings: warnings)
|
|
72
|
+
[entry, result]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../fat/volume"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module Recovery
|
|
7
|
+
class Orphans
|
|
8
|
+
def initialize(volume)
|
|
9
|
+
@volume = volume
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each
|
|
13
|
+
return enum_for(__method__) unless block_given?
|
|
14
|
+
|
|
15
|
+
@volume.each_entry.to_a
|
|
16
|
+
allocation = @volume.allocation_table
|
|
17
|
+
remaining = Set.new((2..(@volume.boot_sector.cluster_count + 1)).select do |cluster|
|
|
18
|
+
allocation.allocated?(cluster) && !@volume.referenced_clusters.include?(cluster)
|
|
19
|
+
end)
|
|
20
|
+
pointed_to = Set.new(remaining.filter_map do |cluster|
|
|
21
|
+
allocation[cluster] if remaining.include?(allocation[cluster])
|
|
22
|
+
end)
|
|
23
|
+
roots = (remaining - pointed_to).to_a + remaining.to_a
|
|
24
|
+
roots.each do |first|
|
|
25
|
+
next unless remaining.include?(first)
|
|
26
|
+
|
|
27
|
+
chain = allocation.chain(first)
|
|
28
|
+
clusters = chain.clusters.take_while { |cluster| remaining.include?(cluster) }
|
|
29
|
+
warnings = chain.warnings + [
|
|
30
|
+
{ code: "ORPHAN_CHAIN", detail: "Chain has no directory entry; original name and file length are unknown" }
|
|
31
|
+
]
|
|
32
|
+
data = "".b
|
|
33
|
+
status = chain.status == "ok" ? "recovered" : chain.status
|
|
34
|
+
clusters.each do |cluster|
|
|
35
|
+
remaining.delete(cluster)
|
|
36
|
+
data << @volume.read_cluster(cluster)
|
|
37
|
+
rescue Error, IOError, SystemCallError => e
|
|
38
|
+
status = "read_error"
|
|
39
|
+
warnings << { code: "READ_ERROR", detail: "Chain #{first}, cluster #{cluster}: #{e.message}" }
|
|
40
|
+
break
|
|
41
|
+
end
|
|
42
|
+
text = data.dup.force_encoding("UTF-8")
|
|
43
|
+
extension = text.valid_encoding? && text.match?(/\b(?:def|end|require|class|module|puts)\b/) ? "rb" : "bin"
|
|
44
|
+
entry = FAT::DirEntry.recovered(path: "CHAIN_#{first}.#{extension}", size: data.bytesize,
|
|
45
|
+
first_cluster: first)
|
|
46
|
+
result = FAT::Volume::ReadResult.new(data: data, status: status, warnings: warnings)
|
|
47
|
+
yield entry, result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../fat/volume"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module Recovery
|
|
7
|
+
class Undelete
|
|
8
|
+
def initialize(volume)
|
|
9
|
+
@volume = volume
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each
|
|
13
|
+
return enum_for(__method__) unless block_given?
|
|
14
|
+
|
|
15
|
+
@volume.each_entry(deleted: true).to_a.each do |entry|
|
|
16
|
+
next unless entry.deleted? && !entry.directory?
|
|
17
|
+
|
|
18
|
+
warnings = [{ code: "DELETED_GUESS",
|
|
19
|
+
detail: "Contiguous free clusters assumed; verify recovered content manually" }]
|
|
20
|
+
data = "".b
|
|
21
|
+
status = "recovered"
|
|
22
|
+
count = (entry.size + @volume.boot_sector.cluster_size - 1) / @volume.boot_sector.cluster_size
|
|
23
|
+
count.times do |index|
|
|
24
|
+
cluster = entry.first_cluster + index
|
|
25
|
+
unless cluster.between?(2, @volume.boot_sector.cluster_count + 1) &&
|
|
26
|
+
@volume.allocation_table[cluster].zero? && !@volume.referenced_clusters.include?(cluster)
|
|
27
|
+
status = "partial"
|
|
28
|
+
warnings << { code: "DELETED_OVERLAP", detail: "Stopped at occupied or invalid cluster #{cluster}" }
|
|
29
|
+
break
|
|
30
|
+
end
|
|
31
|
+
data << @volume.read_cluster(cluster)
|
|
32
|
+
rescue Error, IOError, SystemCallError => e
|
|
33
|
+
status = "read_error"
|
|
34
|
+
warnings << { code: "READ_ERROR", detail: "#{entry.path}, cluster #{cluster}: #{e.message}" }
|
|
35
|
+
break
|
|
36
|
+
end
|
|
37
|
+
yield entry, FAT::Volume::ReadResult.new(data: data.byteslice(0, [entry.size, data.bytesize].min),
|
|
38
|
+
status: status, warnings: warnings)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/sandcrawler.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sandcrawler/version"
|
|
4
|
+
require_relative "sandcrawler/errors"
|
|
5
|
+
require_relative "sandcrawler/log"
|
|
6
|
+
require_relative "sandcrawler/manifest"
|
|
7
|
+
require_relative "sandcrawler/path_sanitizer"
|
|
8
|
+
require_relative "sandcrawler/extractor"
|
|
9
|
+
require_relative "sandcrawler/image/source"
|
|
10
|
+
require_relative "sandcrawler/image/raw_image"
|
|
11
|
+
require_relative "sandcrawler/image/uf2_image"
|
|
12
|
+
require_relative "sandcrawler/image/slice"
|
|
13
|
+
require_relative "sandcrawler/fat/boot_sector"
|
|
14
|
+
require_relative "sandcrawler/fat/allocation_table"
|
|
15
|
+
require_relative "sandcrawler/fat/dir_entry"
|
|
16
|
+
require_relative "sandcrawler/fat/lfn_assembler"
|
|
17
|
+
require_relative "sandcrawler/fat/volume"
|
|
18
|
+
require_relative "sandcrawler/fat/volume_locator"
|
|
19
|
+
require_relative "sandcrawler/recovery/undelete"
|
|
20
|
+
require_relative "sandcrawler/recovery/orphans"
|
|
21
|
+
require_relative "sandcrawler/recovery/carver"
|
|
22
|
+
require_relative "sandcrawler/acquisition/picotool"
|
|
23
|
+
require_relative "sandcrawler/acquisition/mounted_volume"
|
|
24
|
+
require_relative "sandcrawler/acquisition/serial_shell"
|
|
25
|
+
require_relative "sandcrawler/cli"
|