File Coverage

blib/lib/Sys/Export/GPT.pm
Criterion Covered Total %
statement 198 214 92.5
branch 63 96 65.6
condition 25 53 47.1
subroutine 29 30 96.6
pod 14 14 100.0
total 329 407 80.8


line stmt bran cond sub pod time code
1             package Sys::Export::GPT;
2              
3             our $VERSION = '0.005'; # VERSION
4             # ABSTRACT: Implementation of GUID Partition Table
5              
6 2     2   658 use v5.26;
  2         7  
7 2     2   7 use warnings;
  2         10  
  2         115  
8 2     2   8 use experimental qw( signatures );
  2         3  
  2         11  
9 2     2   242 use Scalar::Util qw( blessed );
  2         3  
  2         115  
10 2     2   8 use List::Util qw( min max );
  2         2  
  2         109  
11 2     2   492 use Encode qw( encode );
  2         14866  
  2         133  
12 2     2   13 use Sys::Export::LogAny '$log';
  2         4  
  2         20  
13 2     2   438 use Sys::Export qw( write_file_extent isa_array isa_pow2 isa_handle round_up_to_multiple );
  2         2  
  2         13  
14 2     2   1076 use Sys::Export::GPT::Partition;
  2         5  
  2         55  
15 2     2   10 use Carp;
  2         2  
  2         6580  
16              
17              
18 5     5 1 187938 sub new($class, %attrs) {
  5         9  
  5         17  
  5         7  
19 5         25 my $self= bless {
20             block_size => 512,
21             entry_size => 128,
22             entry_table_lba => 2,
23             partitions => [],
24             partition_align => 4096,
25             }, $class;
26             # Some fields need to be initialized in a specific order:
27 5 50       26 $self->block_size(delete $attrs{block_size}) if defined $attrs{block_size};
28             # The rest have no interdependencies
29 5         25 for (keys %attrs) {
30 11 50       41 my $m= $self->can($_) or croak "Unknown attribute '$_'";
31 11         25 $m->($self, $attrs{$_});
32             }
33 5         25 $self;
34             }
35              
36              
37 31     31 1 1139 sub block_size($self, @v) {
  31         29  
  31         41  
  31         38  
38 31 100       48 if (@v) {
39 5 50       19 croak "Not a power of 2" unless isa_pow2 $v[0];
40 5         14 $self->{block_size}= $v[0];
41 5   50     7 $_->block_size($v[0]) for @{ $self->{partitions} // [] };
  5         37  
42             }
43 31         73 $self->{block_size};
44             }
45              
46              
47 0     0 1 0 sub device_size($self, @v) {
  0         0  
  0         0  
  0         0  
48 0 0       0 if (@v) {
49 0 0       0 croak "Not a multiple of block_size" if $v[0] & ($self->block_size - 1);
50 0         0 $self->{device_size}= $v[0];
51             }
52             $self->{device_size}
53 0         0 }
54              
55              
56 220     220 1 374 sub entry_size($self, @v) {
  220         216  
  220         187  
  220         193  
57 220 100       279 if (@v) {
58 2 50 33     5 croak "Not a power of 2 >= 128" unless isa_pow2 $v[0] && $v[0] >= 128;
59 2         3 $self->{entry_size}= $v[0];
60             }
61 220         527 $self->{entry_size};
62             }
63              
64              
65 28     28 1 286 sub partitions($self, @v) {
  28         51  
  28         31  
  28         27  
66 28 100       42 if (@v) {
67 5 50       14 croak "Not an arrayref" unless isa_array $v[0];
68 5   66     8 $self->{partitions}= [ map $_ && Sys::Export::GPT::Partition->coerce($_), @{$v[0]} ];
  5         61  
69 5         34 $_->block_size($self->block_size) for grep defined, $self->{partitions}->@*;
70             }
71 28   50     88 $self->{partitions} // [];
72             }
73              
74 9 100   9 1 23 sub partition_align { @_ > 1? ($_[0]{partition_align}= $_[1]) : $_[0]{partition_align} }
75              
76              
77 20 100   20 1 81 sub guid { @_ > 1? ($_[0]{guid}= $_[1]) : $_[0]{guid} }
78 22 100   22 1 218 sub entry_table_lba { @_ > 1? ($_[0]{entry_table_lba}= $_[1]) : $_[0]{entry_table_lba} }
79 33 100   33 1 208 sub backup_header_lba { @_ > 1? ($_[0]{backup_header_lba}= $_[1]) : $_[0]{backup_header_lba} }
80 25 100   25 1 204 sub backup_table_lba { @_ > 1? ($_[0]{backup_table_lba}= $_[1]) : $_[0]{backup_table_lba} }
81 40 100   40 1 226 sub first_block { @_ > 1? ($_[0]{first_block}= $_[1]) : $_[0]{first_block} }
82 30 100   30 1 2122 sub last_block { @_ > 1? ($_[0]{last_block}= $_[1]) : $_[0]{last_block} }
83              
84             # Generates a random GUID in the format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
85             # Tries to read from /dev/urandom first (or /dev/random as fallback), then falls back to
86             # Perl's rand() if those aren't available (e.g., on Windows).
87             sub _generate_guid {
88 9     9   10 my @bytes;
89            
90             # Try /dev/urandom first (non-blocking), then /dev/random
91 9         16 for my $dev ('/dev/urandom', '/dev/random') {
92 9 50       350 if (open my $fh, '<:raw', $dev) {
93 9 50       536 if (read($fh, my $bytes, 16) == 16) {
94 9         34 @bytes= unpack 'C*', $bytes;
95 9         107 last;
96             }
97             }
98             }
99            
100             # Fallback to rand() if /dev/random not available (Windows, etc)
101 9 50       19 @bytes= map int(rand 256), 1..16 unless @bytes;
102            
103             # Set version (4) and variant (RFC 4122) bits
104 9         17 $bytes[6] = ($bytes[6] & 0x0f) | 0x40; # Version 4
105 9         25 $bytes[8] = ($bytes[8] & 0x3f) | 0x80; # Variant 10xx
106            
107             # Format as GUID string
108 9         79 sprintf '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x', @bytes;
109             }
110              
111              
112 5     5 1 7 sub choose_missing_geometry($self, $device_size= undef) {
  5         6  
  5         5  
  5         6  
113 5   33     12 $device_size //= $self->device_size;
114 5         8 my $pttn= $self->{partitions};
115 5         8 my $bs= $self->block_size;
116             # Determine number of partition entries. 128 ought to be the defaut, but Sys::Export is
117             # all about generating minimal images, so just round up to a whole block.
118 5         17 my $n_entries= max( scalar @$pttn, 1 );
119 5         11 my $entries_per_block= $bs / $self->entry_size;
120 5 50       23 $n_entries= round_up_to_multiple($n_entries, $entries_per_block)
121             if $entries_per_block > 1;
122 5         19 $#$pttn= $n_entries - 1; # update array length to match
123              
124 5         8 my $n_table_blocks= $n_entries / $entries_per_block;
125              
126 5         8 my $end_lba= int($device_size / $bs) - 1;
127              
128             # header is located at 1, so entries can begin at 2.
129 5   50     10 $self->{entry_table_lba} //= 2;
130              
131             # first_block can start right after that
132 5   66     11 $self->{first_block} //= $self->{entry_table_lba} + $n_table_blocks;
133              
134             # Assign position for any partition not yet defined
135 5         11 my $lba_pos= $self->first_block;
136 5         11 my $block_align= $self->partition_align / $bs;
137 5         41 for (grep defined, @$pttn) {
138 5 100       24 round_up_to_multiple($lba_pos, $block_align) if $block_align > 1;
139 5         14 $_->block_size($self->block_size);
140 5 50       10 if (defined $_->start_lba) {
141 5 50       8 croak "Partition ".$_->name." start_lba ".$_->start_lba." < first_block ".$self->first_block
142             if $_->start_lba < $self->first_block;
143             } else {
144 0         0 $_->start_lba($lba_pos);
145             }
146 5 50       22 unless (defined $_->end_lba) {
147             # Choose based on data size, if available
148 0 0       0 if ($_->data) {
149 0 0       0 my $s= blessed($_->data)? $_->data->size : length ${$_->data};
  0         0  
150 0         0 $_->size(round_up_to_multiple($s, $self->block_size));
151 0         0 $log->debugf("set partition '%s' size to %d based on data length %d",
152             $_->name, $_->size, $s);
153             } else {
154 0         0 croak "No end_lba for partition ".$_->name
155             }
156             }
157 5         20 $log->debugf("partition '%s' start=0x%X end=0x%X", $_->name, $_->start_lba, $_->end_lba);
158 5 50       161 $lba_pos= $_->end_lba + 1 if $_->end_lba >= $lba_pos;
159             }
160 5         8 my $max_part_lba= $lba_pos-1;
161              
162             # If building a minimal image, last_block ends at the maximum partition extent.
163             # But if the handle is an actual block device, or a file larger than max partition,
164             # then count backward from the actual end.
165 5   100     20 my $min_end_lba= max($max_part_lba, $self->{last_block}//0) + 1 + $n_table_blocks;
166             $log->debugf("end_lba = %s, max_part_lba = %s, last_block = %s, need end_lba >= %s",
167 5         14 $end_lba, $max_part_lba, $self->{last_block}, $min_end_lba);
168 5 100       37 if ($end_lba <= $min_end_lba) {
169 1   33     4 $self->{last_block} //= $max_part_lba;
170 1   33     6 $self->{backup_table_lba} //= $self->{last_block} + 1;
171 1   33     4 $self->{backup_header_lba} //= $self->{backup_table_lba} + $n_table_blocks;
172             } else {
173             # work backward from end_lba
174 4   33     8 $self->{backup_header_lba} //= $end_lba;
175 4   33     6 $self->{backup_table_lba} //= $self->{backup_header_lba} - $n_table_blocks;
176 4   33     6 $self->{last_block} //= $self->{backup_table_lba} - 1;
177             }
178 5         9 $end_lba= max($end_lba, $self->{backup_header_lba});
179             $log->debugf("entry_table = %s, first_block = %s, last_block = %s, backup_table = %s, backup_header = %s, end_lba = %s",
180             $self->{entry_table_lba}, $self->{first_block}, $self->{last_block}, $self->{backup_table_lba},
181 5         29 $self->{backup_header_lba}, $end_lba);
182              
183             # Sanity checks
184 5 50       32 $self->entry_table_lba + $n_table_blocks <= $self->first_block
185             or croak "entry array collides with partition area";
186              
187 5 50       10 $self->last_block >= $self->first_block
188             or croak "last_block less than first_block";
189              
190             !defined $pttn->[$_]
191             || $self->first_block <= $pttn->[$_]->start_lba && $pttn->[$_]->end_lba <= $self->last_block
192             || croak "partition $_ exceeds range of [first_block, last_block]"
193 5   33     27 for 0..$#$pttn;
      66        
      50        
194              
195 5 50       11 $self->last_block < $self->backup_table_lba
196             or croak "partition area collides with backup partition entry array";
197              
198 5 50       9 $self->backup_table_lba + $n_table_blocks <= $self->backup_header_lba
199             or croak "backup_header_lba at lower LBA than end of backup partition entry array";
200             }
201              
202              
203 5     5 1 16 sub write_to_file($self, $fh) {
  5         7  
  5         10  
  5         6  
204 5 50       17 croak "No filehandle provided" unless isa_handle $fh;
205 5         9 my $partitions= $self->{partitions};
206 5         11 my $bs= $self->block_size;
207              
208             # choose a GUID if not set
209 5 50       12 defined $self->guid or $self->guid(_generate_guid);
210              
211 5         41 my $size= -s $fh;
212 5         15 $self->choose_missing_geometry($size);
213              
214 5         6 my $max_lba= $self->backup_header_lba;
215 5         31 my $need_size= ($max_lba + 1) * $bs;
216 5 100       9 if ($size < $need_size) {
217 1 50       60 truncate($fh, $need_size)
218             or croak "Can't extend file handle to $need_size bytes";
219             } else {
220 4         8 $max_lba= int($size / $bs) - 1;
221             }
222              
223             # Encode partition entries
224 5         23 my $entries_data= join '', map $self->_pack_partition_entry($_), @$partitions;
225            
226             # Calculate partition entries CRC
227 5         26 my $entries_crc= _crc32($entries_data);
228            
229             # Write protective MBR at LBA 0, but don't alter first 446 bytes of boot loader
230 5         26 write_file_extent($fh, 446, $bs-446,
231             \$self->_pack_protective_mbr($max_lba), 446, 'Protective MBR');
232            
233             # Write primary GPT header at LBA 1
234 5         26 write_file_extent($fh, 1 * $bs, $bs,
235             \$self->_pack_header(0, $entries_crc), 0, 'Primary GPT header');
236            
237             # Write primary partition entries (probably at LBA 2)
238 5         15 write_file_extent($fh, $self->entry_table_lba * $bs, length $entries_data,
239             \$entries_data, 0, 'Primary partition entries');
240              
241             # If any partition defined data, write that
242 5   66     89 for (grep defined && defined $_->data, @$partitions) {
243 0         0 write_file_extent($fh, $_->start_lba * $bs, $_->size,
244             $_->data, 0, 'Partition '.$_->name);
245             }
246              
247             # Write backup partition entries
248 5         10 write_file_extent($fh, $self->backup_table_lba * $bs, length $entries_data,
249             \$entries_data, 0, 'Backup partition entries');
250            
251             # Write backup GPT header at last LBA
252 5         14 write_file_extent($fh, $self->backup_header_lba * $bs, $bs,
253             \$self->_pack_header(1, $entries_crc), 0, 'Backup GPT header');
254            
255 5         26 return 1;
256             }
257              
258             # Creates a protective MBR partiton that marks the entire disk as GPT.
259             # This gets written at offset 446 within the first block and overwrites all 4 partition entries.
260             # This also includes the final 2-byte boot signature.
261 5     5   9 sub _pack_protective_mbr($self, $max_lba) {
  5         10  
  5         6  
  5         7  
262 5         68 pack '@446 C C C C C C C C V V @510 v',
263             # Boot code area and disk ID uses first 446 bytes
264             # Partition entry 1: Protective GPT partition
265             0x00, # Status (inactive)
266             0x00, 0x02, 0x00, # CHS start (0/2/0)
267             0xEE, # Type (GPT protective)
268             0xFF, 0xFF, 0xFF, # CHS end (max)
269             1, # LBA start
270             min($max_lba, 0xFFFFFFFF),# LBA end
271             # skip to 510, pack automatically adds nul bytes for next 3 partitions
272             # Boot signature
273             0xAA55;
274             }
275              
276             # Pure Perl implementation of CRC32 using the standard polynomial (0xEDB88320).
277 15     15   20 sub _crc32($data) {
  15         18  
  15         15  
278             state @table= map {
279 15         24 my $crc= $_;
  512         453  
280             $crc= ($crc & 1)? (0xEDB88320 ^ ($crc >> 1)) : ($crc >> 1)
281 512 100       1847 for 1..8;
282 512         584 $crc
283             } 0..255;
284              
285 15         20 my $crc = 0xFFFFFFFF;
286             $crc= $table[($crc ^ $_) & 0xFF] ^ ($crc >> 8)
287 15         8156 for unpack 'C*', $data;
288 15         500 return $crc ^ 0xFFFFFFFF;
289             }
290              
291             # Convert a hex-notation GUID to binary in the Microsoft encoding
292 20     20   22 sub _pack_guid($guid) {
  20         20  
  20         16  
293             # Microsoft GUIDs are a LE 32-bit int, two LE 16 bit ints, 2 bytes and 6 bytes.
294             # The 8 bytes can be processed as big-endian ints, making it appear mixed-endian.
295 20 50       148 my @ints= (lc($guid) =~ /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})([0-9a-f]{8})\z/)
296             or croak "Invalid GUID format '$guid'";
297 20         137 pack 'VvvnnN', map hex, @ints;
298             }
299              
300             # Encodes a single partition entry as a 128-byte binary structure.
301 200     200   192 sub _pack_partition_entry($self, $part) {
  200         181  
  200         197  
  200         184  
302 200 100       325 return "\0" x $self->entry_size unless defined $part;
303             # Only encode if partition has required fields
304 5 50       11 defined $part->start_lba or croak "Undefined start LBA";
305 5 50       13 defined $part->end_lba or croak "Undefined end LBA";
306 5 50       12 defined $part->type or croak "Missing type attribute";
307 5 100       11 defined $part->guid or $part->guid(_generate_guid);
308 5         11 my $name_max= $self->entry_size - 56;
309 5   50     13 my $name_utf16= encode('UTF-16LE', $part->name // '');
310 5 50       3886 carp "Partition name '".part->name."' was truncated" if length $name_utf16 > $name_max;
311 5         11 $part->block_size($self->block_size); # ensure accurate LBA numbers
312 5   50     16 pack 'a16 a16 Q< Q< Q< a'.$name_max,
313             _pack_guid($part->type), _pack_guid($part->guid), $part->start_lba, $part->end_lba,
314             $part->flags // 0, $name_utf16
315             }
316              
317             # Encodes a GPT header (primary or backup)
318 10     10   14 sub _pack_header($self, $is_backup, $entries_crc) {
  10         14  
  10         11  
  10         9  
  10         9  
319 10 100       21 my $header_lba = $is_backup ? $self->backup_header_lba : 1;
320 10 100       24 my $alt_header_lba = $is_backup ? 1 : $self->backup_header_lba;
321 10 100       25 my $entries_lba = $is_backup ? $self->backup_table_lba : $self->entry_table_lba;
322 10         12 my $num_entries = scalar @{$self->partitions};
  10         20  
323             # Build header without CRC first
324 10         24 my $header = pack('a8 V V V V Q< Q< Q< Q< a16 Q< V V V',
325             'EFI PART', # Signature
326             0x00010000, # Revision 1.0
327             92, # Header size
328             0, # CRC32 (placeholder)
329             0, # Reserved
330             $header_lba, # Current LBA
331             $alt_header_lba, # Alternate LBA
332             $self->first_block, # First usable LBA
333             $self->last_block, # Last usable LBA
334             _pack_guid($self->guid), # Disk GUID
335             $entries_lba, # Partition entries LBA
336             $num_entries, # Number of entries
337             $self->entry_size, # Size of entry
338             $entries_crc # Partition entries CRC32
339             );
340            
341             # Calculate and insert header CRC
342 10         20 my $header_crc = _crc32($header);
343 10         28 substr($header, 16, 4) = pack('V', $header_crc);
344 10         42 return $header;
345             }
346              
347             # Avoiding dependency on namespace::clean
348             delete @{Sys::Export::GPT::}{qw(
349             carp croak confess blessed min max encode write_file_extent round_up_to_multiple
350             isa_array isa_handle isa_pow2
351             )};
352             1;
353              
354             __END__