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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandcrawler
4
+ VERSION = "0.1.0"
5
+ end
@@ -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"