blob: 9eea57e5f60ef2cf4d115195149cd981d3ba1798 [file] [log] [blame]
You-Cheng Syud5692942018-01-04 14:40:59 +08001#!/usr/bin/env python
Hung-Te Linc772e1a2017-04-14 16:50:50 +08002# Copyright 2017 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""An utility to manipulate GPT on a disk image.
7
8Chromium OS factory software usually needs to access partitions from disk
9images. However, there is no good, lightweight, and portable GPT utility.
10Most Chromium OS systems use `cgpt`, but that's not by default installed on
11Ubuntu. Most systems have parted (GNU) or partx (util-linux-ng) but they have
12their own problems.
13
14For example, when a disk image is resized (usually enlarged for putting more
15resources on stateful partition), GPT table must be updated. However,
16 - `parted` can't repair partition without interactive console in exception
17 handler.
18 - `partx` cannot fix headers nor make changes to partition table.
19 - `cgpt repair` does not fix `LastUsableLBA` so we cannot enlarge partition.
20 - `gdisk` is not installed on most systems.
21
22As a result, we need a dedicated tool to help processing GPT.
23
24This pygpt.py provides a simple and customized implementation for processing
25GPT, as a replacement for `cgpt`.
26"""
27
28
29from __future__ import print_function
30
31import argparse
32import binascii
33import collections
34import logging
35import os
36import struct
37import uuid
38
39
40# The binascii.crc32 returns signed integer, so CRC32 in in struct must be
41# declared as 'signed' (l) instead of 'unsigned' (L).
42# http://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_table_header_.28LBA_1.29
43HEADER_FORMAT = """
44 8s Signature
45 4s Revision
46 L HeaderSize
47 l CRC32
48 4s Reserved
49 Q CurrentLBA
50 Q BackupLBA
51 Q FirstUsableLBA
52 Q LastUsableLBA
53 16s DiskGUID
54 Q PartitionEntriesStartingLBA
55 L PartitionEntriesNumber
56 L PartitionEntrySize
57 l PartitionArrayCRC32
58"""
59
60# http://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_entries
61PARTITION_FORMAT = """
62 16s TypeGUID
63 16s UniqueGUID
64 Q FirstLBA
65 Q LastLBA
66 Q Attributes
67 72s Names
68"""
69
70
71def BuildStructFormatAndNamedTuple(name, description):
72 """Builds the format string for struct and create corresponding namedtuple.
73
74 Args:
75 name: A string for name of the named tuple.
76 description: A string with struct descriptor and attribute name.
77
78 Returns:
79 A pair of (struct_format, namedtuple_class).
80 """
81 elements = description.split()
82 struct_format = '<' + ''.join(elements[::2])
83 tuple_class = collections.namedtuple(name, elements[1::2])
84 return (struct_format, tuple_class)
85
86
87class GPT(object):
88 """A GPT helper class.
89
90 To load GPT from an existing disk image file, use `LoadFromFile`.
91 After modifications were made, use `WriteToFile` to commit changes.
92
93 Attributes:
94 header: A namedtuple of GPT header.
95 partitions: A list of GPT partition entry nametuple.
96 """
97
98 HEADER_FORMAT, HEADER_CLASS = BuildStructFormatAndNamedTuple(
99 'Header', HEADER_FORMAT)
100 PARTITION_FORMAT, PARTITION_CLASS = BuildStructFormatAndNamedTuple(
101 'Partition', PARTITION_FORMAT)
102 BLOCK_SIZE = 512
103 HEADER_SIGNATURE = 'EFI PART'
104 TYPE_GUID_UNUSED = '\x00' * 16
105 TYPE_GUID_MAP = {
106 '00000000-0000-0000-0000-000000000000': 'Unused',
107 'EBD0A0A2-B9E5-4433-87C0-68B6B72699C7': 'Linux data',
108 'FE3A2A5D-4F32-41A7-B725-ACCC3285A309': 'ChromeOS kernel',
109 '3CB8E202-3B7E-47DD-8A3C-7FF2A13CFCEC': 'ChromeOS rootfs',
110 '2E0A753D-9E48-43B0-8337-B15192CB1B5E': 'ChromeOS reserved',
111 'CAB6E88E-ABF3-4102-A07A-D4BB9BE3C1D3': 'ChromeOS firmware',
112 'C12A7328-F81F-11D2-BA4B-00A0C93EC93B': 'EFI System Partition',
113 }
114 TYPE_GUID_LIST_BOOTABLE = [
115 'FE3A2A5D-4F32-41A7-B725-ACCC3285A309', # ChromeOS kernel
116 'C12A7328-F81F-11D2-BA4B-00A0C93EC93B', # EFI System Partition
117 ]
118
119 def __init__(self):
120 self.header = None
121 self.partitions = None
122
123 @staticmethod
124 def GetAttributeSuccess(attrs):
125 return (attrs >> 56) & 1
126
127 @staticmethod
128 def GetAttributeTries(attrs):
129 return (attrs >> 52) & 0xf
130
131 @staticmethod
132 def GetAttributePriority(attrs):
133 return (attrs >> 48) & 0xf
134
135 @staticmethod
136 def NewNamedTuple(base, **dargs):
137 """Builds a new named tuple based on dargs."""
138 # pylint: disable=protected-access
139 return base._replace(**dargs)
140
141 @classmethod
142 def ReadHeader(cls, f):
143 return cls.HEADER_CLASS(*struct.unpack(
144 cls.HEADER_FORMAT, f.read(struct.calcsize(cls.HEADER_FORMAT))))
145
146 @classmethod
147 def ReadPartitionEntry(cls, f):
148 return cls.PARTITION_CLASS(*struct.unpack(
149 cls.PARTITION_FORMAT, f.read(struct.calcsize(cls.PARTITION_FORMAT))))
150
151 @classmethod
152 def GetHeaderBlob(cls, header):
153 return struct.pack(cls.HEADER_FORMAT, *header)
154
155 @classmethod
156 def GetHeaderCRC32(cls, header):
157 return binascii.crc32(cls.GetHeaderBlob(cls.NewNamedTuple(header, CRC32=0)))
158
159 @classmethod
160 def GetPartitionsBlob(cls, partitions):
161 return ''.join(struct.pack(cls.PARTITION_FORMAT, *partition)
162 for partition in partitions)
163
164 @classmethod
165 def GetPartitionsCRC32(cls, partitions):
166 return binascii.crc32(cls.GetPartitionsBlob(partitions))
167
168 @classmethod
169 def LoadFromFile(cls, f):
170 """Loads a GPT table from give disk image file object."""
171 gpt = GPT()
172 f.seek(gpt.BLOCK_SIZE * 1)
173 header = gpt.ReadHeader(f)
174 if header.Signature != cls.HEADER_SIGNATURE:
175 raise ValueError('Invalid signature in GPT header.')
176 f.seek(gpt.BLOCK_SIZE * header.PartitionEntriesStartingLBA)
177 partitions = [gpt.ReadPartitionEntry(f)
178 for unused_i in range(header.PartitionEntriesNumber)]
179 gpt.header = header
180 gpt.partitions = partitions
181 return gpt
182
183 def GetValidPartitions(self):
184 """Returns the list of partitions before entry with empty type GUID.
185
186 In partition table, the first entry with empty type GUID indicates end of
187 valid partitions. In most implementations all partitions after that should
188 be zeroed.
189 """
190 for i, p in enumerate(self.partitions):
191 if p.TypeGUID == self.TYPE_GUID_UNUSED:
192 return self.partitions[:i]
193 return self.partitions
194
195 def GetMaxUsedLBA(self):
196 """Returns the max LastLBA from all valid partitions."""
197 return max(p.LastLBA for p in self.GetValidPartitions())
198
199 def GetMinUsedLBA(self):
200 """Returns the min FirstLBA from all valid partitions."""
201 return min(p.FirstLBA for p in self.GetValidPartitions())
202
203 def GetPartitionTableBlocks(self, header=None):
204 """Returns the blocks (or LBA) of partition table from given header."""
205 if header is None:
206 header = self.header
207 size = header.PartitionEntrySize * header.PartitionEntriesNumber
208 blocks = size / self.BLOCK_SIZE
209 if size % self.BLOCK_SIZE:
210 blocks += 1
211 return blocks
212
213 def Resize(self, new_size):
214 """Adjust GPT for a disk image in given size.
215
216 Args:
217 new_size: Integer for new size of disk image file.
218 """
219 old_size = self.BLOCK_SIZE * (self.header.BackupLBA + 1)
220 if new_size % self.BLOCK_SIZE:
221 raise ValueError('New file size %d is not valid for image files.' %
222 new_size)
223 new_blocks = new_size / self.BLOCK_SIZE
224 if old_size != new_size:
225 logging.warn('Image size (%d, LBA=%d) changed from %d (LBA=%d).',
226 new_size, new_blocks, old_size, old_size / self.BLOCK_SIZE)
227 else:
228 logging.info('Image size (%d, LBA=%d) not changed.',
229 new_size, new_blocks)
230
231 # Re-calculate all related fields.
232 backup_lba = new_blocks - 1
233 partitions_blocks = self.GetPartitionTableBlocks()
234
235 # To add allow adding more blocks for partition table, we should reserve
236 # same space between primary and backup partition tables and real
237 # partitions.
238 min_used_lba = self.GetMinUsedLBA()
239 max_used_lba = self.GetMaxUsedLBA()
240 primary_reserved = min_used_lba - self.header.PartitionEntriesStartingLBA
241 primary_last_lba = (self.header.PartitionEntriesStartingLBA +
242 partitions_blocks - 1)
243
244 if primary_last_lba >= min_used_lba:
245 raise ValueError('Partition table overlaps partitions.')
246 if max_used_lba + partitions_blocks >= backup_lba:
247 raise ValueError('Partitions overlaps backup partition table.')
248
249 last_usable_lba = backup_lba - primary_reserved - 1
250 if last_usable_lba < max_used_lba:
251 last_usable_lba = max_used_lba
252
253 self.header = self.NewNamedTuple(
254 self.header,
255 BackupLBA=backup_lba,
256 LastUsableLBA=last_usable_lba)
257
258 def GetFreeSpace(self):
259 """Returns the free (available) space left according to LastUsableLBA."""
260 max_lba = self.GetMaxUsedLBA()
261 assert max_lba <= self.header.LastUsableLBA, "Partitions too large."
262 return self.BLOCK_SIZE * (self.header.LastUsableLBA - max_lba)
263
264 def ExpandPartition(self, i):
265 """Expands a given partition to last usable LBA.
266
267 Args:
268 i: Index (0-based) of target partition.
269 """
270 # Assume no partitions overlap, we need to make sure partition[i] has
271 # largest LBA.
272 if i < 0 or i >= len(self.GetValidPartitions()):
273 raise ValueError('Partition index %d is invalid.' % (i + 1))
274 p = self.partitions[i]
275 max_used_lba = self.GetMaxUsedLBA()
276 if max_used_lba > p.LastLBA:
277 raise ValueError('Cannot expand partition %d because it is not the last '
278 'allocated partition.' % (i + 1))
279
280 old_blocks = p.LastLBA - p.FirstLBA + 1
281 p = self.NewNamedTuple(p, LastLBA=self.header.LastUsableLBA)
282 new_blocks = p.LastLBA - p.FirstLBA + 1
283 self.partitions[i] = p
284 logging.warn('Partition NR=%d expanded, size in LBA: %d -> %d.',
285 i + 1, old_blocks, new_blocks)
286
287 def UpdateChecksum(self):
288 """Updates all checksum values in GPT header."""
289 header = self.NewNamedTuple(
290 self.header,
291 CRC32=0,
292 PartitionArrayCRC32=self.GetPartitionsCRC32(self.partitions))
293 self.header = self.NewNamedTuple(
294 header,
295 CRC32=self.GetHeaderCRC32(header))
296
297 def GetBackupHeader(self):
298 """Returns the backup header according to current header."""
299 partitions_starting_lba = (
300 self.header.BackupLBA - self.GetPartitionTableBlocks())
301 header = self.NewNamedTuple(
302 self.header,
303 CRC32=0,
304 BackupLBA=self.header.CurrentLBA,
305 CurrentLBA=self.header.BackupLBA,
306 PartitionEntriesStartingLBA=partitions_starting_lba)
307 return self.NewNamedTuple(
308 header,
309 CRC32=self.GetHeaderCRC32(header))
310
311 def WriteToFile(self, f):
312 """Updates partition table in a disk image file."""
313
314 def WriteData(name, blob, lba):
315 """Writes a blob into given location."""
316 logging.info('Writing %s in LBA %d (offset %d)',
317 name, lba, lba * self.BLOCK_SIZE)
318 f.seek(lba * self.BLOCK_SIZE)
319 f.write(blob)
320
321 self.UpdateChecksum()
322 WriteData('GPT Header', self.GetHeaderBlob(self.header),
323 self.header.CurrentLBA)
324 WriteData('GPT Partitions', self.GetPartitionsBlob(self.partitions),
325 self.header.PartitionEntriesStartingLBA)
326 logging.info('Usable LBA: First=%d, Last=%d',
327 self.header.FirstUsableLBA, self.header.LastUsableLBA)
328 backup_header = self.GetBackupHeader()
329 WriteData('Backup Partitions', self.GetPartitionsBlob(self.partitions),
330 backup_header.PartitionEntriesStartingLBA)
331 WriteData('Backup Header', self.GetHeaderBlob(backup_header),
332 backup_header.CurrentLBA)
333
334
335class GPTCommands(object):
336 """Collection of GPT sub commands for command line to use.
337
338 The commands are derived from `cgpt`, but not necessary to be 100% compatible
339 with cgpt.
340 """
341
342 FORMAT_ARGS = [
Peter Shihc7156ca2018-02-26 14:46:24 +0800343 ('begin', 'beginning sector'),
344 ('size', 'partition size'),
345 ('type', 'type guid'),
346 ('unique', 'unique guid'),
347 ('label', 'label'),
348 ('Successful', 'Successful flag'),
349 ('Tries', 'Tries flag'),
350 ('Priority', 'Priority flag'),
351 ('Legacy', 'Legacy Boot flag'),
352 ('Attribute', 'raw 16-bit attribute value (bits 48-63)')]
Hung-Te Linc772e1a2017-04-14 16:50:50 +0800353
354 def __init__(self):
355 pass
356
357 @classmethod
358 def RegisterRepair(cls, p):
359 """Registers the repair command to argparser.
360
361 Args:
362 p: An argparse parser instance.
363 """
364 p.add_argument(
365 '--expand', action='store_true', default=False,
366 help='Expands stateful partition to full disk.')
367 p.add_argument('image_file', type=argparse.FileType('rb+'),
368 help='Disk image file to repair.')
369
370 def Repair(self, args):
371 """Repair damaged GPT headers and tables."""
372 gpt = GPT.LoadFromFile(args.image_file)
373 gpt.Resize(os.path.getsize(args.image_file.name))
374
375 free_space = gpt.GetFreeSpace()
376 if args.expand:
377 if free_space:
378 gpt.ExpandPartition(0)
379 else:
380 logging.warn('- No extra space to expand.')
381 elif free_space:
382 logging.warn('Extra space found (%d, LBA=%d), '
Peter Shihc7156ca2018-02-26 14:46:24 +0800383 'use --expand to expand partitions.',
384 free_space, free_space / gpt.BLOCK_SIZE)
Hung-Te Linc772e1a2017-04-14 16:50:50 +0800385
386 gpt.WriteToFile(args.image_file)
387 print('Disk image file %s repaired.' % args.image_file.name)
388
389 @classmethod
390 def RegisterShow(cls, p):
391 """Registers the repair command to argparser.
392
393 Args:
394 p: An argparse parser instance.
395 """
396 p.add_argument('--numeric', '-n', action='store_true',
397 help='Numeric output only.')
398 p.add_argument('--quick', '-q', action='store_true',
399 help='Quick output.')
400 p.add_argument('--index', '-i', type=int, default=None,
401 help='Show specified partition only, with format args.')
402 for name, help_str in cls.FORMAT_ARGS:
403 # TODO(hungte) Alert if multiple args were specified.
404 p.add_argument('--%s' % name, '-%c' % name[0], action='store_true',
405 help='[format] %s.' % help_str)
406 p.add_argument('image_file', type=argparse.FileType('rb'),
407 help='Disk image file to show.')
408
409
410 def Show(self, args):
411 """Show partition table and entries."""
412
413 def FormatGUID(bytes_le):
414 return str(uuid.UUID(bytes_le=bytes_le)).upper()
415
416 def FormatTypeGUID(p):
417 guid_str = FormatGUID(p.TypeGUID)
418 if not args.numeric:
419 names = gpt.TYPE_GUID_MAP.get(guid_str)
420 if names:
421 return names
422 return guid_str
423
424 def FormatNames(p):
425 return p.Names.decode('utf-16-le').strip('\0')
426
427 def IsBootableType(type_guid):
428 return type_guid in gpt.TYPE_GUID_LIST_BOOTABLE
429
430 def FormatAttribute(attr):
431 if args.numeric:
432 return '[%x]' % (attr >> 48)
433 if attr & 4:
434 return 'legacy_boot=1'
435 return 'priority=%d tries=%d successful=%d' % (
436 gpt.GetAttributePriority(attr),
437 gpt.GetAttributeTries(attr),
438 gpt.GetAttributeSuccess(attr))
439
440 def ApplyFormatArgs(p):
441 if args.begin:
442 return p.FirstLBA
443 elif args.size:
444 return p.LastLBA - p.FirstLBA + 1
445 elif args.type:
446 return FormatTypeGUID(p)
447 elif args.unique:
448 return FormatGUID(p.UniqueGUID)
449 elif args.label:
450 return FormatNames(p)
451 elif args.Successful:
452 return gpt.GetAttributeSuccess(p.Attributes)
453 elif args.Priority:
454 return gpt.GetAttributePriority(p.Attributes)
455 elif args.Tries:
456 return gpt.GetAttributeTries(p.Attributes)
457 elif args.Legacy:
458 raise NotImplementedError
459 elif args.Attribute:
460 return '[%x]' % (p.Attributes >> 48)
461 else:
462 return None
463
464 def IsFormatArgsSpecified():
465 return any(getattr(args, arg[0]) for arg in self.FORMAT_ARGS)
466
467 gpt = GPT.LoadFromFile(args.image_file)
468 fmt = '%12s %11s %7s %s'
469 fmt2 = '%32s %s: %s'
470 header = ('start', 'size', 'part', 'contents')
471
472 if IsFormatArgsSpecified() and args.index is None:
473 raise ValueError('Format arguments must be used with -i.')
474
475 partitions = gpt.GetValidPartitions()
476 if not (args.index is None or 0 < args.index <= len(partitions)):
477 raise ValueError('Invalid partition index: %d' % args.index)
478
479 do_print_gpt_blocks = False
480 if not (args.quick or IsFormatArgsSpecified()):
481 print(fmt % header)
482 if args.index is None:
483 do_print_gpt_blocks = True
484
485 if do_print_gpt_blocks:
486 print(fmt % (gpt.header.CurrentLBA, 1, '', 'Pri GPT header'))
487 print(fmt % (gpt.header.PartitionEntriesStartingLBA,
488 gpt.GetPartitionTableBlocks(), '', 'Pri GPT table'))
489
490 for i, p in enumerate(partitions):
491 if args.index is not None and i != args.index - 1:
492 continue
493
494 if IsFormatArgsSpecified():
495 print(ApplyFormatArgs(p))
496 continue
497
498 type_guid = FormatGUID(p.TypeGUID)
499 print(fmt % (p.FirstLBA, p.LastLBA - p.FirstLBA + 1, i + 1,
500 FormatTypeGUID(p) if args.quick else
501 'Label: "%s"' % FormatNames(p)))
502
503 if not args.quick:
504 print(fmt2 % ('', 'Type', FormatTypeGUID(p)))
505 print(fmt2 % ('', 'UUID', FormatGUID(p.UniqueGUID)))
506 if args.numeric or IsBootableType(type_guid):
507 print(fmt2 % ('', 'Attr', FormatAttribute(p.Attributes)))
508
509 if do_print_gpt_blocks:
510 f = args.image_file
511 f.seek(gpt.header.BackupLBA * gpt.BLOCK_SIZE)
512 backup_header = gpt.ReadHeader(f)
513 print(fmt % (backup_header.PartitionEntriesStartingLBA,
514 gpt.GetPartitionTableBlocks(backup_header), '',
515 'Sec GPT table'))
516 print(fmt % (gpt.header.BackupLBA, 1, '', 'Sec GPT header'))
517
518 def Create(self, args):
519 """Create or reset GPT headers and tables."""
520 del args # Not used yet.
521 raise NotImplementedError
522
523 def Add(self, args):
524 """Add, edit or remove a partition entry."""
525 del args # Not used yet.
526 raise NotImplementedError
527
528 def Boot(self, args):
529 """Edit the PMBR sector for legacy BIOSes."""
530 del args # Not used yet.
531 raise NotImplementedError
532
533 def Find(self, args):
534 """Locate a partition by its GUID."""
535 del args # Not used yet.
536 raise NotImplementedError
537
538 def Prioritize(self, args):
539 """Reorder the priority of all kernel partitions."""
540 del args # Not used yet.
541 raise NotImplementedError
542
543 def Legacy(self, args):
544 """Switch between GPT and Legacy GPT."""
545 del args # Not used yet.
546 raise NotImplementedError
547
548 @classmethod
549 def RegisterAllCommands(cls, subparsers):
550 """Registers all available commands to an argparser subparsers instance."""
551 subcommands = [('show', cls.Show, cls.RegisterShow),
552 ('repair', cls.Repair, cls.RegisterRepair)]
553 for name, invocation, register_command in subcommands:
554 register_command(subparsers.add_parser(name, help=invocation.__doc__))
555
556
557def main():
558 parser = argparse.ArgumentParser(description='GPT Utility.')
559 parser.add_argument('--verbose', '-v', action='count', default=0,
560 help='increase verbosity.')
561 parser.add_argument('--debug', '-d', action='store_true',
562 help='enable debug output.')
563 subparsers = parser.add_subparsers(help='Sub-command help.', dest='command')
564 GPTCommands.RegisterAllCommands(subparsers)
565
566 args = parser.parse_args()
567 log_level = max(logging.WARNING - args.verbose * 10, logging.DEBUG)
568 if args.debug:
569 log_level = logging.DEBUG
570 logging.basicConfig(format='%(module)s:%(funcName)s %(message)s',
571 level=log_level)
572 commands = GPTCommands()
573 try:
574 getattr(commands, args.command.capitalize())(args)
575 except Exception as e:
576 if args.verbose or args.debug:
577 logging.exception('Failure in command [%s]', args.command)
578 exit('ERROR: %s' % e)
579
580
581if __name__ == '__main__':
582 main()