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,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