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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Sandcrawler
|
|
6
|
+
module FAT
|
|
7
|
+
class AllocationTable
|
|
8
|
+
Chain = Struct.new(:clusters, :status, :warnings, keyword_init: true)
|
|
9
|
+
attr_reader :warnings, :selection, :copies_consistent
|
|
10
|
+
|
|
11
|
+
def initialize(source, boot_sector, fat: nil)
|
|
12
|
+
@boot = boot_sector
|
|
13
|
+
@bits = @boot.fat_type.delete_prefix("FAT").to_i
|
|
14
|
+
@warnings = []
|
|
15
|
+
@erased_sectors = Set.new
|
|
16
|
+
fat ||= @boot.active_fat
|
|
17
|
+
raise UsageError, "FAT must be 1 or 2" if fat && ![1, 2].include?(fat)
|
|
18
|
+
raise UsageError, "FAT#{fat} does not exist" if fat && fat > @boot.fat_count
|
|
19
|
+
|
|
20
|
+
bytes = @boot.fat_sectors * @boot.bytes_per_sector
|
|
21
|
+
first = source.read_at(@boot.reserved_sectors * @boot.bytes_per_sector, bytes)
|
|
22
|
+
if @boot.fat_count == 2
|
|
23
|
+
second = source.read_at((@boot.reserved_sectors + @boot.fat_sectors) * @boot.bytes_per_sector,
|
|
24
|
+
bytes)
|
|
25
|
+
end
|
|
26
|
+
@copies_consistent = !second || first == second
|
|
27
|
+
@selection = "fat#{fat || 1}"
|
|
28
|
+
@data = first.dup
|
|
29
|
+
selected = Set.new
|
|
30
|
+
(0...bytes).step(@boot.bytes_per_sector) do |offset|
|
|
31
|
+
a = first.byteslice(offset, @boot.bytes_per_sector)
|
|
32
|
+
b = second&.byteslice(offset, @boot.bytes_per_sector)
|
|
33
|
+
if b && a != b
|
|
34
|
+
@warnings << { code: "FAT_MISMATCH",
|
|
35
|
+
detail: "FAT copies differ at sector #{offset / @boot.bytes_per_sector}" }
|
|
36
|
+
end
|
|
37
|
+
choice = fat || (b && erased?(a) && !erased?(b) ? 2 : 1)
|
|
38
|
+
selected << choice
|
|
39
|
+
sector = choice == 2 ? b : a
|
|
40
|
+
if erased?(sector)
|
|
41
|
+
@erased_sectors << (offset / @boot.bytes_per_sector)
|
|
42
|
+
@warnings << { code: "FAT_ERASED",
|
|
43
|
+
detail: "Selected FAT sector #{offset / @boot.bytes_per_sector} is erased" }
|
|
44
|
+
end
|
|
45
|
+
@data[offset, sector.bytesize] = sector
|
|
46
|
+
end
|
|
47
|
+
@selection = selected.size == 1 ? "fat#{selected.first}" : "mixed"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def [](cluster)
|
|
51
|
+
raise OutOfRangeError, "FAT index outside allocation table" unless cluster.between?(0, @boot.cluster_count + 1)
|
|
52
|
+
|
|
53
|
+
if @bits == 12
|
|
54
|
+
value = @data.byteslice(cluster + (cluster / 2), 2).unpack1("v")
|
|
55
|
+
cluster.even? ? value & 0xFFF : value >> 4
|
|
56
|
+
elsif @bits == 16
|
|
57
|
+
@data.byteslice(cluster * 2, 2).unpack1("v")
|
|
58
|
+
else
|
|
59
|
+
@data.byteslice(cluster * 4, 4).unpack1("V") & 0x0FFFFFFF
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def eoc?(value)
|
|
64
|
+
value >= { 12 => 0xFF8, 16 => 0xFFF8, 32 => 0x0FFFFFF8 }.fetch(@bits)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def allocated?(cluster)
|
|
68
|
+
return false if erased_entry?(cluster)
|
|
69
|
+
|
|
70
|
+
value = self[cluster]
|
|
71
|
+
value.positive? && value != bad_marker && !reserved?(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def chain(first, limit: nil)
|
|
75
|
+
clusters = []
|
|
76
|
+
visited = Set.new
|
|
77
|
+
warnings = []
|
|
78
|
+
current = first
|
|
79
|
+
status = "ok"
|
|
80
|
+
loop do
|
|
81
|
+
if !current.between?(2, @boot.cluster_count + 1) || visited.include?(current)
|
|
82
|
+
status = "chain_error"
|
|
83
|
+
warnings << { code: "CHAIN_ERROR", detail: "Invalid or repeated cluster #{current}" }
|
|
84
|
+
break
|
|
85
|
+
end
|
|
86
|
+
visited << current
|
|
87
|
+
clusters << current
|
|
88
|
+
if erased_entry?(current)
|
|
89
|
+
status = "chain_error"
|
|
90
|
+
warnings << { code: "FAT_ERASED_ENTRY", detail: "Allocation metadata for cluster #{current} is erased" }
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
following = self[current]
|
|
94
|
+
break if eoc?(following)
|
|
95
|
+
|
|
96
|
+
if following.zero?
|
|
97
|
+
status = "truncated"
|
|
98
|
+
warnings << { code: "CHAIN_FREE", detail: "Cluster #{current} points to free space" }
|
|
99
|
+
break
|
|
100
|
+
end
|
|
101
|
+
if following == bad_marker || reserved?(following)
|
|
102
|
+
status = "chain_error"
|
|
103
|
+
warnings << { code: "CHAIN_ERROR", detail: "Bad or reserved link at cluster #{current}" }
|
|
104
|
+
break
|
|
105
|
+
end
|
|
106
|
+
if limit && clusters.length >= limit
|
|
107
|
+
status = "chain_error"
|
|
108
|
+
warnings << { code: "CHAIN_TOO_LONG",
|
|
109
|
+
detail: "Chain continues beyond declared file size at cluster #{current}" }
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
current = following
|
|
113
|
+
end
|
|
114
|
+
Chain.new(clusters: clusters, status: status, warnings: warnings)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def erased_entry?(cluster)
|
|
120
|
+
offset = @bits == 12 ? cluster + (cluster / 2) : cluster * (@bits / 8)
|
|
121
|
+
length = @bits == 32 ? 4 : 2
|
|
122
|
+
@erased_sectors.include?(offset / @boot.bytes_per_sector) ||
|
|
123
|
+
@erased_sectors.include?((offset + length - 1) / @boot.bytes_per_sector)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def bad_marker
|
|
127
|
+
{ 12 => 0xFF7, 16 => 0xFFF7, 32 => 0x0FFFFFF7 }.fetch(@bits)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def reserved?(value)
|
|
131
|
+
value == 1 || value.between?(bad_marker - 7, bad_marker - 1)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def erased?(sector)
|
|
135
|
+
sector.bytes.all? { |byte| byte == 255 }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandcrawler
|
|
4
|
+
module FAT
|
|
5
|
+
class BootSector
|
|
6
|
+
attr_reader :bytes_per_sector, :sectors_per_cluster, :reserved_sectors, :fat_count,
|
|
7
|
+
:root_entry_count, :total_sectors, :fat_sectors, :media, :root_cluster,
|
|
8
|
+
:root_dir_sectors, :first_data_sector, :cluster_count, :fat_type, :label, :active_fat, :backup_sector
|
|
9
|
+
|
|
10
|
+
def initialize(bytes, available_size: nil)
|
|
11
|
+
raise FormatError, "Boot sector is shorter than 512 bytes" if bytes.bytesize < 512
|
|
12
|
+
unless (bytes.getbyte(0) == 0xEB && bytes.getbyte(2) == 0x90) || bytes.getbyte(0) == 0xE9
|
|
13
|
+
raise FormatError, "Invalid FAT jump instruction"
|
|
14
|
+
end
|
|
15
|
+
raise FormatError, "Missing FAT signature" unless bytes.byteslice(510, 2) == "\x55\xAA".b
|
|
16
|
+
|
|
17
|
+
@bytes_per_sector = bytes.byteslice(11, 2).unpack1("v")
|
|
18
|
+
@sectors_per_cluster = bytes.getbyte(13)
|
|
19
|
+
@reserved_sectors = bytes.byteslice(14, 2).unpack1("v")
|
|
20
|
+
@fat_count = bytes.getbyte(16)
|
|
21
|
+
@root_entry_count = bytes.byteslice(17, 2).unpack1("v")
|
|
22
|
+
@total_sectors = bytes.byteslice(19, 2).unpack1("v")
|
|
23
|
+
@total_sectors = bytes.byteslice(32, 4).unpack1("V") if @total_sectors.zero?
|
|
24
|
+
@media = bytes.getbyte(21)
|
|
25
|
+
@fat_sectors = bytes.byteslice(22, 2).unpack1("v")
|
|
26
|
+
@fat_sectors = bytes.byteslice(36, 4).unpack1("V") if @fat_sectors.zero?
|
|
27
|
+
validate_geometry(available_size)
|
|
28
|
+
@root_dir_sectors = ((root_entry_count * 32) + bytes_per_sector - 1) / bytes_per_sector
|
|
29
|
+
@first_data_sector = reserved_sectors + (fat_count * fat_sectors) + root_dir_sectors
|
|
30
|
+
@cluster_count = (total_sectors - first_data_sector) / sectors_per_cluster
|
|
31
|
+
raise FormatError, "FAT volume has no data clusters" unless cluster_count.positive?
|
|
32
|
+
|
|
33
|
+
@fat_type = if cluster_count < 4085
|
|
34
|
+
"FAT12"
|
|
35
|
+
else
|
|
36
|
+
cluster_count < 65_525 ? "FAT16" : "FAT32"
|
|
37
|
+
end
|
|
38
|
+
@root_cluster = fat_type == "FAT32" ? bytes.byteslice(44, 4).unpack1("V") & 0x0FFFFFFF : nil
|
|
39
|
+
validate_fat(bytes)
|
|
40
|
+
if fat_type == "FAT32"
|
|
41
|
+
@backup_sector = bytes.byteslice(50, 2).unpack1("v")
|
|
42
|
+
flags = bytes.byteslice(40, 2).unpack1("v")
|
|
43
|
+
@active_fat = (flags & 15) + 1 unless (flags & 0x80).zero?
|
|
44
|
+
raise FormatError, "Active FAT index exceeds FAT count" if active_fat && active_fat > fat_count
|
|
45
|
+
end
|
|
46
|
+
@label = bytes.byteslice(fat_type == "FAT32" ? 71 : 43, 11).delete("\0").strip.encode("UTF-8", "IBM437")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.from_geometry(values, available_size:)
|
|
50
|
+
geometry = values.transform_keys(&:to_sym)
|
|
51
|
+
bps, spc, reserved, fats, fatsz, root = %i[bps spc rsvd fats fatsz root].map do |key|
|
|
52
|
+
Integer(geometry.fetch(key))
|
|
53
|
+
end
|
|
54
|
+
unless [512, 1024, 2048, 4096].include?(bps) && spc.between?(1, 128) &&
|
|
55
|
+
reserved.between?(1, 65_535) && [1, 2].include?(fats) &&
|
|
56
|
+
fatsz.between?(1, root.zero? ? 0xFFFFFFFF : 65_535) && root.between?(0, 65_535)
|
|
57
|
+
raise FormatError, "Manual geometry contains out-of-range fields"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
total = Integer(geometry.fetch(:total) { geometry.fetch(:totsec, available_size / bps) })
|
|
61
|
+
raise FormatError, "Invalid total sector count" unless total.between?(1, 0xFFFFFFFF)
|
|
62
|
+
|
|
63
|
+
media = Integer(geometry.fetch(:media, 0xF8))
|
|
64
|
+
raise FormatError, "Invalid manual media byte" unless media.between?(0, 255)
|
|
65
|
+
|
|
66
|
+
root_cluster = Integer(geometry.fetch(:root_cluster, 2))
|
|
67
|
+
raise FormatError, "Invalid root cluster" if root.zero? && !root_cluster.between?(2, 0x0FFFFFFF)
|
|
68
|
+
|
|
69
|
+
bytes = "\0".b * 512
|
|
70
|
+
bytes[0, 3] = "\xEB\x3C\x90".b
|
|
71
|
+
bytes[11, 2] = [bps].pack("v")
|
|
72
|
+
bytes.setbyte(13, spc)
|
|
73
|
+
bytes[14, 2] = [reserved].pack("v")
|
|
74
|
+
bytes.setbyte(16, fats)
|
|
75
|
+
bytes[17, 2] = [root].pack("v")
|
|
76
|
+
bytes[32, 4] = [total].pack("V")
|
|
77
|
+
bytes.setbyte(21, media)
|
|
78
|
+
if root.zero?
|
|
79
|
+
bytes[36, 4] = [fatsz].pack("V")
|
|
80
|
+
bytes[44, 4] = [root_cluster].pack("V")
|
|
81
|
+
else
|
|
82
|
+
bytes[22, 2] = [fatsz].pack("v")
|
|
83
|
+
end
|
|
84
|
+
bytes[510, 2] = "\x55\xAA".b
|
|
85
|
+
new(bytes, available_size: available_size)
|
|
86
|
+
rescue KeyError, ArgumentError, TypeError, RangeError, ZeroDivisionError => e
|
|
87
|
+
raise FormatError, "Invalid manual geometry: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cluster_size
|
|
91
|
+
bytes_per_sector * sectors_per_cluster
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def byte_size
|
|
95
|
+
total_sectors * bytes_per_sector
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def cluster_offset(cluster)
|
|
99
|
+
raise OutOfRangeError, "Invalid cluster #{cluster}" unless cluster.between?(2, cluster_count + 1)
|
|
100
|
+
|
|
101
|
+
(first_data_sector + ((cluster - 2) * sectors_per_cluster)) * bytes_per_sector
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def to_h
|
|
105
|
+
{ fat_type: fat_type, bytes_per_sector: bytes_per_sector, sectors_per_cluster: sectors_per_cluster,
|
|
106
|
+
reserved_sectors: reserved_sectors, fat_count: fat_count, fat_sectors: fat_sectors,
|
|
107
|
+
root_entry_count: root_entry_count, root_cluster: root_cluster, total_sectors: total_sectors,
|
|
108
|
+
cluster_count: cluster_count, label: label }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def validate_geometry(available_size)
|
|
114
|
+
raise FormatError, "Invalid FAT sector size" unless [512, 1024, 2048, 4096].include?(bytes_per_sector)
|
|
115
|
+
unless sectors_per_cluster.between?(1, 128) && (sectors_per_cluster & (sectors_per_cluster - 1)).zero?
|
|
116
|
+
raise FormatError, "Invalid FAT cluster size"
|
|
117
|
+
end
|
|
118
|
+
unless reserved_sectors.positive? && [1, 2].include?(fat_count) &&
|
|
119
|
+
total_sectors.positive? && fat_sectors.positive?
|
|
120
|
+
raise FormatError, "Invalid FAT geometry"
|
|
121
|
+
end
|
|
122
|
+
raise FormatError, "Invalid FAT media byte" unless media == 0xF0 || media.between?(0xF8, 0xFF)
|
|
123
|
+
raise FormatError, "FAT volume exceeds source" if available_size && byte_size > available_size
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_fat(bytes)
|
|
127
|
+
bits = fat_type.delete_prefix("FAT").to_i
|
|
128
|
+
required_bytes = (((cluster_count + 2) * bits) + 7) / 8
|
|
129
|
+
raise FormatError, "Allocation table is too short" if required_bytes > fat_sectors * bytes_per_sector
|
|
130
|
+
|
|
131
|
+
if fat_type == "FAT32"
|
|
132
|
+
unless root_entry_count.zero? && root_cluster.between?(2, cluster_count + 1) &&
|
|
133
|
+
bytes.byteslice(22, 2).unpack1("v").zero?
|
|
134
|
+
raise FormatError, "Invalid FAT32 root geometry"
|
|
135
|
+
end
|
|
136
|
+
elsif root_entry_count.zero?
|
|
137
|
+
raise FormatError, "Missing fixed root directory"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Sandcrawler
|
|
7
|
+
module FAT
|
|
8
|
+
class DirEntry
|
|
9
|
+
attr_accessor :path
|
|
10
|
+
attr_reader :name, :size, :mtime, :attributes, :first_cluster
|
|
11
|
+
|
|
12
|
+
def initialize(bytes, fat_type: "FAT12", codepage: "IBM437", name: nil, parent: "")
|
|
13
|
+
raise FormatError, "Directory entry must contain 32 bytes" unless bytes.bytesize == 32
|
|
14
|
+
|
|
15
|
+
@deleted = bytes.getbyte(0) == 0xE5
|
|
16
|
+
@directory = (bytes.getbyte(11) & 0x10).positive?
|
|
17
|
+
@attributes = { 1 => "readonly", 2 => "hidden", 4 => "system", 8 => "volume_label", 16 => "directory",
|
|
18
|
+
32 => "archive" }
|
|
19
|
+
.filter_map do |bit, label|
|
|
20
|
+
label if (bytes.getbyte(11) & bit).positive?
|
|
21
|
+
end
|
|
22
|
+
@name = name || short_name(bytes, codepage)
|
|
23
|
+
@path = parent.empty? ? @name : "#{parent}/#{@name}"
|
|
24
|
+
@size = bytes.byteslice(28, 4).unpack1("V")
|
|
25
|
+
@first_cluster = bytes.byteslice(26, 2).unpack1("v")
|
|
26
|
+
@first_cluster |= (bytes.byteslice(20, 2).unpack1("v") & 0xFFF) << 16 if fat_type == "FAT32"
|
|
27
|
+
@mtime = decode_time(bytes.byteslice(24, 2).unpack1("v"), bytes.byteslice(22, 2).unpack1("v"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.recovered(path:, size:, first_cluster: nil)
|
|
31
|
+
bytes = (" " * 11) + ("\0" * 21)
|
|
32
|
+
bytes.setbyte(11, 0x20)
|
|
33
|
+
bytes[28, 4] = [size].pack("V")
|
|
34
|
+
bytes[26, 2] = [(first_cluster || 0) & 0xFFFF].pack("v")
|
|
35
|
+
bytes[20, 2] = [(first_cluster || 0) >> 16].pack("v")
|
|
36
|
+
new(bytes, fat_type: "FAT32", name: path.split("/").last).tap { |entry| entry.path = path }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def directory?
|
|
40
|
+
@directory
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deleted?
|
|
44
|
+
@deleted
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{ path: path, original_name: name, size: size, mtime: mtime&.iso8601,
|
|
49
|
+
attributes: attributes, first_cluster: first_cluster, deleted: deleted?, directory: directory? }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def short_name(bytes, codepage)
|
|
55
|
+
short = bytes.byteslice(0, 11).dup
|
|
56
|
+
short.setbyte(0, 0x5F) if deleted?
|
|
57
|
+
short.setbyte(0, 0xE5) if short.getbyte(0) == 0x05
|
|
58
|
+
base = short.byteslice(0, 8).rstrip.encode("UTF-8", codepage, invalid: :replace, undef: :replace)
|
|
59
|
+
extension = short.byteslice(8, 3).rstrip.encode("UTF-8", codepage, invalid: :replace, undef: :replace)
|
|
60
|
+
base = base.downcase if (bytes.getbyte(12) & 0x08).positive?
|
|
61
|
+
extension = extension.downcase if (bytes.getbyte(12) & 0x10).positive?
|
|
62
|
+
extension.empty? ? base : "#{base}.#{extension}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def decode_time(date, time)
|
|
66
|
+
year = 1980 + (date >> 9)
|
|
67
|
+
month = (date >> 5) & 15
|
|
68
|
+
day = date & 31
|
|
69
|
+
hour = time >> 11
|
|
70
|
+
minute = (time >> 5) & 63
|
|
71
|
+
second = (time & 31) * 2
|
|
72
|
+
return unless Date.valid_date?(year, month, day) && hour < 24 && minute < 60 && second < 60
|
|
73
|
+
|
|
74
|
+
Time.local(year, month, day, hour, minute, second)
|
|
75
|
+
rescue ArgumentError, RangeError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandcrawler
|
|
4
|
+
module FAT
|
|
5
|
+
class LfnAssembler
|
|
6
|
+
def initialize
|
|
7
|
+
@entries = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def add(bytes)
|
|
11
|
+
@entries.clear if bytes.getbyte(0) != 0xE5 && (bytes.getbyte(0) & 0x40).positive?
|
|
12
|
+
@entries << bytes
|
|
13
|
+
@entries.clear if @entries.length > 20
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def take(short)
|
|
17
|
+
entries = @entries
|
|
18
|
+
@entries = []
|
|
19
|
+
return if entries.empty?
|
|
20
|
+
|
|
21
|
+
deleted = short.getbyte(0) == 0xE5
|
|
22
|
+
return unless valid_entries?(entries, short, deleted)
|
|
23
|
+
|
|
24
|
+
units = entries.reverse.flat_map do |bytes|
|
|
25
|
+
(bytes.byteslice(1, 10) + bytes.byteslice(14, 12) + bytes.byteslice(28, 4)).unpack("v*")
|
|
26
|
+
end
|
|
27
|
+
units = units.take_while { |unit| unit != 0 && unit != 0xFFFF }
|
|
28
|
+
return if units.empty? || units.length > 255
|
|
29
|
+
|
|
30
|
+
units.pack("v*").force_encoding("UTF-16LE").encode("UTF-8")
|
|
31
|
+
rescue EncodingError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear
|
|
36
|
+
@entries.clear
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def valid_entries?(entries, short, deleted)
|
|
42
|
+
return false unless entries.all? { |entry| entry.getbyte(12).zero? && entry.byteslice(26, 2) == "\0\0" }
|
|
43
|
+
|
|
44
|
+
if deleted
|
|
45
|
+
entries.all? { |entry| entry.getbyte(0) == 0xE5 } && entries.map { |entry| entry.getbyte(13) }.uniq.size == 1
|
|
46
|
+
else
|
|
47
|
+
count = entries.first.getbyte(0) & 31
|
|
48
|
+
return false unless entries.first.getbyte(0) == (count | 0x40) && count == entries.length
|
|
49
|
+
|
|
50
|
+
checksum = short.byteslice(0, 11).bytes.reduce(0) { |sum, byte| (((sum & 1) << 7) + (sum >> 1) + byte) & 255 }
|
|
51
|
+
entries.each_with_index.all? do |entry, index|
|
|
52
|
+
expected = count - index
|
|
53
|
+
expected |= 0x40 if index.zero?
|
|
54
|
+
entry.getbyte(0) == expected && entry.getbyte(13) == checksum
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "../image/slice"
|
|
5
|
+
require_relative "boot_sector"
|
|
6
|
+
require_relative "allocation_table"
|
|
7
|
+
require_relative "dir_entry"
|
|
8
|
+
require_relative "lfn_assembler"
|
|
9
|
+
|
|
10
|
+
module Sandcrawler
|
|
11
|
+
module FAT
|
|
12
|
+
class Volume
|
|
13
|
+
ReadResult = Struct.new(:data, :status, :warnings, keyword_init: true)
|
|
14
|
+
attr_reader :boot_sector, :allocation_table, :warnings, :referenced_clusters, :source, :offset
|
|
15
|
+
|
|
16
|
+
def initialize(source, offset: 0, boot_sector: nil, fat: nil, codepage: "IBM437")
|
|
17
|
+
unless offset.is_a?(Integer) && offset >= 0 && offset < source.size
|
|
18
|
+
raise OutOfRangeError,
|
|
19
|
+
"Volume offset outside source"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Encoding.find(codepage)
|
|
23
|
+
@offset = offset
|
|
24
|
+
@boot_sector = boot_sector || BootSector.new(source.read_at(offset, 512), available_size: source.size - offset)
|
|
25
|
+
@source = Image::Slice.new(source, offset, @boot_sector.byte_size)
|
|
26
|
+
@allocation_table = AllocationTable.new(@source, @boot_sector, fat: fat)
|
|
27
|
+
@warnings = @allocation_table.warnings.dup
|
|
28
|
+
@referenced_clusters = Set.new
|
|
29
|
+
@cluster_owners = {}
|
|
30
|
+
@crosslinked_clusters = Set.new
|
|
31
|
+
@ambiguous_paths = Set.new
|
|
32
|
+
@indexed = false
|
|
33
|
+
@codepage = codepage
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def metadata
|
|
37
|
+
boot_sector.to_h.merge(offset: offset, fat_selection: allocation_table.selection,
|
|
38
|
+
fat_copies_consistent: allocation_table.copies_consistent)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def each_entry(recursive: true, deleted: false)
|
|
42
|
+
return enum_for(__method__, recursive: recursive, deleted: deleted) unless block_given?
|
|
43
|
+
|
|
44
|
+
pending = [["", boot_sector.root_cluster]]
|
|
45
|
+
seen_directories = Set.new
|
|
46
|
+
seen_paths = Set.new
|
|
47
|
+
until pending.empty?
|
|
48
|
+
path, cluster = pending.shift
|
|
49
|
+
if cluster && seen_directories.include?(cluster)
|
|
50
|
+
warn_once("DIRECTORY_LOOP", "Directory cluster #{cluster} is already visited")
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
seen_directories << cluster if cluster
|
|
54
|
+
directory_data(cluster, path).then do |data|
|
|
55
|
+
parse_entries(data, path, deleted: deleted).each do |entry|
|
|
56
|
+
unless entry.deleted?
|
|
57
|
+
if seen_paths.include?(entry.path)
|
|
58
|
+
@ambiguous_paths << entry.path
|
|
59
|
+
warn_once("DUPLICATE_ENTRY", "Multiple live directory entries use #{entry.path}")
|
|
60
|
+
end
|
|
61
|
+
seen_paths << entry.path
|
|
62
|
+
track_entry(entry)
|
|
63
|
+
pending << [entry.path, entry.first_cluster] if entry.directory? && recursive
|
|
64
|
+
end
|
|
65
|
+
yield entry
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
@indexed = true if recursive && !deleted
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read(entry)
|
|
73
|
+
each_entry.to_a unless @indexed
|
|
74
|
+
warnings = []
|
|
75
|
+
if @ambiguous_paths.include?(entry.path)
|
|
76
|
+
warnings << { code: "DUPLICATE_ENTRY", detail: "Multiple live directory entries use #{entry.path}" }
|
|
77
|
+
end
|
|
78
|
+
if entry.size.zero?
|
|
79
|
+
return ReadResult.new(data: "".b, status: warnings.empty? ? "ok" : "chain_error", warnings: warnings)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
limit = (entry.size + boot_sector.cluster_size - 1) / boot_sector.cluster_size
|
|
83
|
+
chain = allocation_table.chain(entry.first_cluster, limit: limit)
|
|
84
|
+
data = "".b
|
|
85
|
+
warnings += chain.warnings
|
|
86
|
+
erased = false
|
|
87
|
+
chain.clusters.each do |cluster|
|
|
88
|
+
bytes = read_cluster(cluster)
|
|
89
|
+
erased ||= bytes.bytes.all? { |byte| byte == 255 }
|
|
90
|
+
data << bytes
|
|
91
|
+
end
|
|
92
|
+
status = @ambiguous_paths.include?(entry.path) ? "chain_error" : chain.status
|
|
93
|
+
if chain.clusters.any? { |cluster| @crosslinked_clusters.include?(cluster) }
|
|
94
|
+
status = "chain_error"
|
|
95
|
+
warnings << { code: "CROSS_LINK", detail: "#{entry.path} shares clusters with another directory entry" }
|
|
96
|
+
end
|
|
97
|
+
status = "truncated" if status == "ok" && data.bytesize < entry.size
|
|
98
|
+
if erased
|
|
99
|
+
warnings << { code: "ERASED_CLUSTER", detail: "#{entry.path} contains an erased cluster" }
|
|
100
|
+
status = "suspect_erased" if status == "ok"
|
|
101
|
+
end
|
|
102
|
+
ReadResult.new(data: data.byteslice(0, [entry.size, data.bytesize].min), status: status, warnings: warnings)
|
|
103
|
+
rescue Error, IOError, SystemCallError => e
|
|
104
|
+
ReadResult.new(data: data || "".b, status: "chain_error",
|
|
105
|
+
warnings: (warnings || []) + [{ code: "READ_ERROR", detail: e.message }])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_cluster(cluster)
|
|
109
|
+
source.read_at(boot_sector.cluster_offset(cluster), boot_sector.cluster_size)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def directory_data(cluster, path)
|
|
115
|
+
unless cluster
|
|
116
|
+
position = (boot_sector.reserved_sectors + (boot_sector.fat_count * boot_sector.fat_sectors)) *
|
|
117
|
+
boot_sector.bytes_per_sector
|
|
118
|
+
return source.read_at(position, boot_sector.root_entry_count * 32)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
chain = allocation_table.chain(cluster)
|
|
122
|
+
track_clusters(chain.clusters, path)
|
|
123
|
+
chain.warnings.each { |warning| warn_once(warning[:code], warning[:detail]) }
|
|
124
|
+
data = "".b
|
|
125
|
+
chain.clusters.each { |number| data << read_cluster(number) }
|
|
126
|
+
data
|
|
127
|
+
rescue Error, IOError, SystemCallError => e
|
|
128
|
+
warn_once("DIRECTORY_ERROR", e.message)
|
|
129
|
+
data || "".b
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_entries(data, parent, deleted:)
|
|
133
|
+
entries = []
|
|
134
|
+
lfn = LfnAssembler.new
|
|
135
|
+
past_end = false
|
|
136
|
+
(0...data.bytesize).step(32) do |position|
|
|
137
|
+
bytes = data.byteslice(position, 32)
|
|
138
|
+
break if bytes.bytesize < 32
|
|
139
|
+
|
|
140
|
+
if bytes.getbyte(0).zero?
|
|
141
|
+
past_end = true
|
|
142
|
+
lfn.clear
|
|
143
|
+
break unless deleted
|
|
144
|
+
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
if past_end && bytes.getbyte(0) != 0xE5
|
|
148
|
+
lfn.clear
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
if bytes.bytes.all? { |byte| byte == 255 } || (bytes.getbyte(11) & 0xC0).positive?
|
|
152
|
+
warn_once("DIRECTORY_DAMAGED", "Invalid directory entry in #{parent.empty? ? '/' : parent}")
|
|
153
|
+
lfn.clear
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
if bytes.getbyte(11) == 0x0F
|
|
157
|
+
lfn.add(bytes)
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
name = lfn.take(bytes)
|
|
161
|
+
next if (bytes.getbyte(11) & 0x08).positive? || (bytes.getbyte(0) == 0xE5 && !deleted)
|
|
162
|
+
next if [". ", ".. "].include?(bytes.byteslice(0, 11))
|
|
163
|
+
|
|
164
|
+
entry = DirEntry.new(bytes, fat_type: boot_sector.fat_type, codepage: @codepage, name: name, parent: parent)
|
|
165
|
+
next if [".", "..", ""].include?(entry.name)
|
|
166
|
+
|
|
167
|
+
entries << entry
|
|
168
|
+
end
|
|
169
|
+
entries
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def track_entry(entry)
|
|
173
|
+
return if entry.first_cluster < 2
|
|
174
|
+
return if entry.size.zero? && !entry.directory?
|
|
175
|
+
|
|
176
|
+
limit = entry.directory? ? nil : (entry.size + boot_sector.cluster_size - 1) / boot_sector.cluster_size
|
|
177
|
+
track_clusters(allocation_table.chain(entry.first_cluster, limit: limit).clusters, entry.path)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def track_clusters(clusters, owner)
|
|
181
|
+
@referenced_clusters.merge(clusters)
|
|
182
|
+
clusters.each do |cluster|
|
|
183
|
+
if @cluster_owners.key?(cluster) && @cluster_owners[cluster] != owner
|
|
184
|
+
@crosslinked_clusters << cluster
|
|
185
|
+
warn_once("CROSS_LINK", "Cluster #{cluster} is shared by #{@cluster_owners[cluster]} and #{owner}")
|
|
186
|
+
else
|
|
187
|
+
@cluster_owners[cluster] = owner
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def warn_once(code, detail)
|
|
193
|
+
warning = { code: code, detail: detail }
|
|
194
|
+
warnings << warning unless warnings.include?(warning)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|