blob: 052f813f8200b58f3f9029a44e7d8909fccc822f [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."""
Hung-Te Linc772e1a2017-04-14 16:50:50 +0800138 return base._replace(**dargs)
139
140 @classmethod
141 def ReadHeader(cls, f):
142 return cls.HEADER_CLASS(*struct.unpack(
143 cls.HEADER_FORMAT, f.read(struct.calcsize(cls.HEADER_FORMAT))))
144
145 @classmethod
146 def ReadPartitionEntry(cls, f):
147 return cls.PARTITION_CLASS(*struct.unpack(
148 cls.PARTITION_FORMAT, f.read(struct.calcsize(cls.PARTITION_FORMAT))))
149
150 @classmethod
151 def GetHeaderBlob(cls, header):
152 return struct.pack(cls.HEADER_FORMAT, *header)
153
154 @classmethod
155 def GetHeaderCRC32(cls, header):
156 return binascii.crc32(cls.GetHeaderBlob(cls.NewNamedTuple(header, CRC32=0)))
157
158 @classmethod
159 def GetPartitionsBlob(cls, partitions):
160 return ''.join(struct.pack(cls.PARTITION_FORMAT, *partition)
161 for partition in partitions)
162
163 @classmethod
164 def GetPartitionsCRC32(cls, partitions):
165 return binascii.crc32(cls.GetPartitionsBlob(partitions))
166
167 @classmethod
168 def LoadFromFile(cls, f):
169 """Loads a GPT table from give disk image file object."""
170 gpt = GPT()
171 f.seek(gpt.BLOCK_SIZE * 1)
172 header = gpt.ReadHeader(f)
173 if header.Signature != cls.HEADER_SIGNATURE:
174 raise ValueError('Invalid signature in GPT header.')
175 f.seek(gpt.BLOCK_SIZE * header.PartitionEntriesStartingLBA)
176 partitions = [gpt.ReadPartitionEntry(f)
177 for unused_i in range(header.PartitionEntriesNumber)]
178 gpt.header = header
179 gpt.partitions = partitions
180 return gpt
181
182 def GetValidPartitions(self):
183 """Returns the list of partitions before entry with empty type GUID.
184
185 In partition table, the first entry with empty type GUID indicates end of
186 valid partitions. In most implementations all partitions after that should
187 be zeroed.
188 """
189 for i, p in enumerate(self.partitions):
190 if p.TypeGUID == self.TYPE_GUID_UNUSED:
191 return self.partitions[:i]
192 return self.partitions
193
194 def GetMaxUsedLBA(self):
195 """Returns the max LastLBA from all valid partitions."""
196 return max(p.LastLBA for p in self.GetValidPartitions())
197
198 def GetMinUsedLBA(self):
199 """Returns the min FirstLBA from all valid partitions."""
200 return min(p.FirstLBA for p in self.GetValidPartitions())
201
202 def GetPartitionTableBlocks(self, header=None):
203 """Returns the blocks (or LBA) of partition table from given header."""
204 if header is None:
205 header = self.header
206 size = header.PartitionEntrySize * header.PartitionEntriesNumber
207 blocks = size / self.BLOCK_SIZE
208 if size % self.BLOCK_SIZE:
209 blocks += 1
210 return blocks
211
212 def Resize(self, new_size):
213 """Adjust GPT for a disk image in given size.
214
215 Args:
216 new_size: Integer for new size of disk image file.
217 """
218 old_size = self.BLOCK_SIZE * (self.header.BackupLBA + 1)
219 if new_size % self.BLOCK_SIZE:
220 raise ValueError('New file size %d is not valid for image files.' %
221 new_size)
222 new_blocks = new_size / self.BLOCK_SIZE
223 if old_size != new_size:
224 logging.warn('Image size (%d, LBA=%d) changed from %d (LBA=%d).',
225 new_size, new_blocks, old_size, old_size / self.BLOCK_SIZE)
226 else:
227 logging.info('Image size (%d, LBA=%d) not changed.',
228 new_size, new_blocks)
229
230 # Re-calculate all related fields.
231 backup_lba = new_blocks - 1
232 partitions_blocks = self.GetPartitionTableBlocks()
233
234 # To add allow adding more blocks for partition table, we should reserve
235 # same space between primary and backup partition tables and real
236 # partitions.
237 min_used_lba = self.GetMinUsedLBA()
238 max_used_lba = self.GetMaxUsedLBA()
239 primary_reserved = min_used_lba - self.header.PartitionEntriesStartingLBA
240 primary_last_lba = (self.header.PartitionEntriesStartingLBA +
241 partitions_blocks - 1)
242
243 if primary_last_lba >= min_used_lba:
244 raise ValueError('Partition table overlaps partitions.')
245 if max_used_lba + partitions_blocks >= backup_lba:
246 raise ValueError('Partitions overlaps backup partition table.')
247
248 last_usable_lba = backup_lba - primary_reserved - 1
249 if last_usable_lba < max_used_lba:
250 last_usable_lba = max_used_lba
251
252 self.header = self.NewNamedTuple(
253 self.header,
254 BackupLBA=backup_lba,
255 LastUsableLBA=last_usable_lba)
256
257 def GetFreeSpace(self):
258 """Returns the free (available) space left according to LastUsableLBA."""
259 max_lba = self.GetMaxUsedLBA()
260 assert max_lba <= self.header.LastUsableLBA, "Partitions too large."
261 return self.BLOCK_SIZE * (self.header.LastUsableLBA - max_lba)
262
263 def ExpandPartition(self, i):
264 """Expands a given partition to last usable LBA.
265
266 Args:
267 i: Index (0-based) of target partition.
268 """
269 # Assume no partitions overlap, we need to make sure partition[i] has
270 # largest LBA.
271 if i < 0 or i >= len(self.GetValidPartitions()):
272 raise ValueError('Partition index %d is invalid.' % (i + 1))
273 p = self.partitions[i]
274 max_used_lba = self.GetMaxUsedLBA()
275 if max_used_lba > p.LastLBA:
276 raise ValueError('Cannot expand partition %d because it is not the last '
277 'allocated partition.' % (i + 1))
278
279 old_blocks = p.LastLBA - p.FirstLBA + 1
280 p = self.NewNamedTuple(p, LastLBA=self.header.LastUsableLBA)
281 new_blocks = p.LastLBA - p.FirstLBA + 1
282 self.partitions[i] = p
283 logging.warn('Partition NR=%d expanded, size in LBA: %d -> %d.',
284 i + 1, old_blocks, new_blocks)
285
286 def UpdateChecksum(self):
287 """Updates all checksum values in GPT header."""
288 header = self.NewNamedTuple(
289 self.header,
290 CRC32=0,
291 PartitionArrayCRC32=self.GetPartitionsCRC32(self.partitions))
292 self.header = self.NewNamedTuple(
293 header,
294 CRC32=self.GetHeaderCRC32(header))
295
296 def GetBackupHeader(self):
297 """Returns the backup header according to current header."""
298 partitions_starting_lba = (
299 self.header.BackupLBA - self.GetPartitionTableBlocks())
300 header = self.NewNamedTuple(
301 self.header,
302 CRC32=0,
303 BackupLBA=self.header.CurrentLBA,
304 CurrentLBA=self.header.BackupLBA,
305 PartitionEntriesStartingLBA=partitions_starting_lba)
306 return self.NewNamedTuple(
307 header,
308 CRC32=self.GetHeaderCRC32(header))
309
310 def WriteToFile(self, f):
311 """Updates partition table in a disk image file."""
312
313 def WriteData(name, blob, lba):
314 """Writes a blob into given location."""
315 logging.info('Writing %s in LBA %d (offset %d)',
316 name, lba, lba * self.BLOCK_SIZE)
317 f.seek(lba * self.BLOCK_SIZE)
318 f.write(blob)
319
320 self.UpdateChecksum()
321 WriteData('GPT Header', self.GetHeaderBlob(self.header),
322 self.header.CurrentLBA)
323 WriteData('GPT Partitions', self.GetPartitionsBlob(self.partitions),
324 self.header.PartitionEntriesStartingLBA)
325 logging.info('Usable LBA: First=%d, Last=%d',
326 self.header.FirstUsableLBA, self.header.LastUsableLBA)
327 backup_header = self.GetBackupHeader()
328 WriteData('Backup Partitions', self.GetPartitionsBlob(self.partitions),
329 backup_header.PartitionEntriesStartingLBA)
330 WriteData('Backup Header', self.GetHeaderBlob(backup_header),
331 backup_header.CurrentLBA)
332
333
334class GPTCommands(object):
335 """Collection of GPT sub commands for command line to use.
336
337 The commands are derived from `cgpt`, but not necessary to be 100% compatible
338 with cgpt.
339 """
340
341 FORMAT_ARGS = [
Peter Shihc7156ca2018-02-26 14:46:24 +0800342 ('begin', 'beginning sector'),
343 ('size', 'partition size'),
344 ('type', 'type guid'),
345 ('unique', 'unique guid'),
346 ('label', 'label'),
347 ('Successful', 'Successful flag'),
348 ('Tries', 'Tries flag'),
349 ('Priority', 'Priority flag'),
350 ('Legacy', 'Legacy Boot flag'),
351 ('Attribute', 'raw 16-bit attribute value (bits 48-63)')]
Hung-Te Linc772e1a2017-04-14 16:50:50 +0800352
353 def __init__(self):
354 pass
355
356 @classmethod
357 def RegisterRepair(cls, p):
358 """Registers the repair command to argparser.
359
360 Args:
361 p: An argparse parser instance.
362 """
363 p.add_argument(
364 '--expand', action='store_true', default=False,
365 help='Expands stateful partition to full disk.')
366 p.add_argument('image_file', type=argparse.FileType('rb+'),
367 help='Disk image file to repair.')
368
369 def Repair(self, args):
370 """Repair damaged GPT headers and tables."""
371 gpt = GPT.LoadFromFile(args.image_file)
372 gpt.Resize(os.path.getsize(args.image_file.name))
373
374 free_space = gpt.GetFreeSpace()
375 if args.expand:
376 if free_space:
377 gpt.ExpandPartition(0)
378 else:
379 logging.warn('- No extra space to expand.')
380 elif free_space:
381 logging.warn('Extra space found (%d, LBA=%d), '
Peter Shihc7156ca2018-02-26 14:46:24 +0800382 'use --expand to expand partitions.',
383 free_space, free_space / gpt.BLOCK_SIZE)
Hung-Te Linc772e1a2017-04-14 16:50:50 +0800384
385 gpt.WriteToFile(args.image_file)
386 print('Disk image file %s repaired.' % args.image_file.name)
387
388 @classmethod
389 def RegisterShow(cls, p):
390 """Registers the repair command to argparser.
391
392 Args:
393 p: An argparse parser instance.
394 """
395 p.add_argument('--numeric', '-n', action='store_true',
396 help='Numeric output only.')
397 p.add_argument('--quick', '-q', action='store_true',
398 help='Quick output.')
399 p.add_argument('--index', '-i', type=int, default=None,
400 help='Show specified partition only, with format args.')
401 for name, help_str in cls.FORMAT_ARGS:
402 # TODO(hungte) Alert if multiple args were specified.
403 p.add_argument('--%s' % name, '-%c' % name[0], action='store_true',
404 help='[format] %s.' % help_str)
405 p.add_argument('image_file', type=argparse.FileType('rb'),
406 help='Disk image file to show.')
407
408
409 def Show(self, args):
410 """Show partition table and entries."""
411
412 def FormatGUID(bytes_le):
413 return str(uuid.UUID(bytes_le=bytes_le)).upper()
414
415 def FormatTypeGUID(p):
416 guid_str = FormatGUID(p.TypeGUID)
417 if not args.numeric:
418 names = gpt.TYPE_GUID_MAP.get(guid_str)
419 if names:
420 return names
421 return guid_str
422
423 def FormatNames(p):
424 return p.Names.decode('utf-16-le').strip('\0')
425
426 def IsBootableType(type_guid):
427 return type_guid in gpt.TYPE_GUID_LIST_BOOTABLE
428
429 def FormatAttribute(attr):
430 if args.numeric:
431 return '[%x]' % (attr >> 48)
432 if attr & 4:
433 return 'legacy_boot=1'
434 return 'priority=%d tries=%d successful=%d' % (
435 gpt.GetAttributePriority(attr),
436 gpt.GetAttributeTries(attr),
437 gpt.GetAttributeSuccess(attr))
438
439 def ApplyFormatArgs(p):
440 if args.begin:
441 return p.FirstLBA
442 elif args.size:
443 return p.LastLBA - p.FirstLBA + 1
444 elif args.type:
445 return FormatTypeGUID(p)
446 elif args.unique:
447 return FormatGUID(p.UniqueGUID)
448 elif args.label:
449 return FormatNames(p)
450 elif args.Successful:
451 return gpt.GetAttributeSuccess(p.Attributes)
452 elif args.Priority:
453 return gpt.GetAttributePriority(p.Attributes)
454 elif args.Tries:
455 return gpt.GetAttributeTries(p.Attributes)
456 elif args.Legacy:
457 raise NotImplementedError
458 elif args.Attribute:
459 return '[%x]' % (p.Attributes >> 48)
460 else:
461 return None
462
463 def IsFormatArgsSpecified():
464 return any(getattr(args, arg[0]) for arg in self.FORMAT_ARGS)
465
466 gpt = GPT.LoadFromFile(args.image_file)
467 fmt = '%12s %11s %7s %s'
468 fmt2 = '%32s %s: %s'
469 header = ('start', 'size', 'part', 'contents')
470
471 if IsFormatArgsSpecified() and args.index is None:
472 raise ValueError('Format arguments must be used with -i.')
473
474 partitions = gpt.GetValidPartitions()
475 if not (args.index is None or 0 < args.index <= len(partitions)):
476 raise ValueError('Invalid partition index: %d' % args.index)
477
478 do_print_gpt_blocks = False
479 if not (args.quick or IsFormatArgsSpecified()):
480 print(fmt % header)
481 if args.index is None:
482 do_print_gpt_blocks = True
483
484 if do_print_gpt_blocks:
485 print(fmt % (gpt.header.CurrentLBA, 1, '', 'Pri GPT header'))
486 print(fmt % (gpt.header.PartitionEntriesStartingLBA,
487 gpt.GetPartitionTableBlocks(), '', 'Pri GPT table'))
488
489 for i, p in enumerate(partitions):
490 if args.index is not None and i != args.index - 1:
491 continue
492
493 if IsFormatArgsSpecified():
494 print(ApplyFormatArgs(p))
495 continue
496
497 type_guid = FormatGUID(p.TypeGUID)
498 print(fmt % (p.FirstLBA, p.LastLBA - p.FirstLBA + 1, i + 1,
499 FormatTypeGUID(p) if args.quick else
500 'Label: "%s"' % FormatNames(p)))
501
502 if not args.quick:
503 print(fmt2 % ('', 'Type', FormatTypeGUID(p)))
504 print(fmt2 % ('', 'UUID', FormatGUID(p.UniqueGUID)))
505 if args.numeric or IsBootableType(type_guid):
506 print(fmt2 % ('', 'Attr', FormatAttribute(p.Attributes)))
507
508 if do_print_gpt_blocks:
509 f = args.image_file
510 f.seek(gpt.header.BackupLBA * gpt.BLOCK_SIZE)
511 backup_header = gpt.ReadHeader(f)
512 print(fmt % (backup_header.PartitionEntriesStartingLBA,
513 gpt.GetPartitionTableBlocks(backup_header), '',
514 'Sec GPT table'))
515 print(fmt % (gpt.header.BackupLBA, 1, '', 'Sec GPT header'))
516
517 def Create(self, args):
518 """Create or reset GPT headers and tables."""
519 del args # Not used yet.
520 raise NotImplementedError
521
522 def Add(self, args):
523 """Add, edit or remove a partition entry."""
524 del args # Not used yet.
525 raise NotImplementedError
526
527 def Boot(self, args):
528 """Edit the PMBR sector for legacy BIOSes."""
529 del args # Not used yet.
530 raise NotImplementedError
531
532 def Find(self, args):
533 """Locate a partition by its GUID."""
534 del args # Not used yet.
535 raise NotImplementedError
536
537 def Prioritize(self, args):
538 """Reorder the priority of all kernel partitions."""
539 del args # Not used yet.
540 raise NotImplementedError
541
542 def Legacy(self, args):
543 """Switch between GPT and Legacy GPT."""
544 del args # Not used yet.
545 raise NotImplementedError
546
547 @classmethod
548 def RegisterAllCommands(cls, subparsers):
549 """Registers all available commands to an argparser subparsers instance."""
550 subcommands = [('show', cls.Show, cls.RegisterShow),
551 ('repair', cls.Repair, cls.RegisterRepair)]
552 for name, invocation, register_command in subcommands:
553 register_command(subparsers.add_parser(name, help=invocation.__doc__))
554
555
556def main():
557 parser = argparse.ArgumentParser(description='GPT Utility.')
558 parser.add_argument('--verbose', '-v', action='count', default=0,
559 help='increase verbosity.')
560 parser.add_argument('--debug', '-d', action='store_true',
561 help='enable debug output.')
562 subparsers = parser.add_subparsers(help='Sub-command help.', dest='command')
563 GPTCommands.RegisterAllCommands(subparsers)
564
565 args = parser.parse_args()
566 log_level = max(logging.WARNING - args.verbose * 10, logging.DEBUG)
567 if args.debug:
568 log_level = logging.DEBUG
569 logging.basicConfig(format='%(module)s:%(funcName)s %(message)s',
570 level=log_level)
571 commands = GPTCommands()
572 try:
573 getattr(commands, args.command.capitalize())(args)
574 except Exception as e:
575 if args.verbose or args.debug:
576 logging.exception('Failure in command [%s]', args.command)
577 exit('ERROR: %s' % e)
578
579
580if __name__ == '__main__':
581 main()