# coding=utf8 # Copyright (c) 2012 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Utility functions to handle patches.""" import posixpath import os import re class UnsupportedPatchFormat(Exception): def __init__(self, filename, status): super(UnsupportedPatchFormat, self).__init__(filename, status) self.filename = filename self.status = status def __str__(self): out = 'Can\'t process patch for file %s.' % self.filename if self.status: out += '\n%s' % self.status return out class FilePatchBase(object): """Defines a single file being modified. '/' is always used instead of os.sep for consistency. """ is_delete = False is_binary = False is_new = False def __init__(self, filename): assert self.__class__ is not FilePatchBase self.filename = self._process_filename(filename) # Set when the file is copied or moved. self.source_filename = None @property def filename_utf8(self): return self.filename.encode('utf-8') @property def source_filename_utf8(self): if self.source_filename is not None: return self.source_filename.encode('utf-8') @staticmethod def _process_filename(filename): filename = filename.replace('\\', '/') # Blacklist a few characters for simplicity. for i in ('$', '..', '\'', '"', '<', '>', ':', '|', '?', '*'): if i in filename: raise UnsupportedPatchFormat( filename, 'Can\'t use \'%s\' in filename.' % i) if filename.startswith('/'): raise UnsupportedPatchFormat( filename, 'Filename can\'t start with \'/\'.') if filename == 'CON': raise UnsupportedPatchFormat( filename, 'Filename can\'t be \'CON\'.') if re.match(r'COM\d', filename): raise UnsupportedPatchFormat( filename, 'Filename can\'t be \'%s\'.' % filename) return filename def set_relpath(self, relpath): if not relpath: return relpath = relpath.replace('\\', '/') if relpath[0] == '/': self._fail('Relative path starts with %s' % relpath[0]) self.filename = self._process_filename( posixpath.join(relpath, self.filename)) if self.source_filename: self.source_filename = self._process_filename( posixpath.join(relpath, self.source_filename)) def _fail(self, msg): """Shortcut function to raise UnsupportedPatchFormat.""" raise UnsupportedPatchFormat(self.filename, msg) def __str__(self): # Use a status-like board. out = '' if self.is_binary: out += 'B' else: out += ' ' if self.is_delete: out += 'D' else: out += ' ' if self.is_new: out += 'N' else: out += ' ' if self.source_filename: out += 'R' else: out += ' ' out += ' ' if self.source_filename: out += '%s->' % self.source_filename_utf8 return out + self.filename_utf8 def dump(self): """Dumps itself in a verbose way to help diagnosing.""" return str(self) class FilePatchDelete(FilePatchBase): """Deletes a file.""" is_delete = True def __init__(self, filename, is_binary): super(FilePatchDelete, self).__init__(filename) self.is_binary = is_binary class FilePatchBinary(FilePatchBase): """Content of a new binary file.""" is_binary = True def __init__(self, filename, data, svn_properties, is_new): super(FilePatchBinary, self).__init__(filename) self.data = data self.svn_properties = svn_properties or [] self.is_new = is_new def get(self): return self.data def __str__(self): return str(super(FilePatchBinary, self)) + ' %d bytes' % len(self.data) class Hunk(object): """Parsed hunk data container.""" def __init__(self, start_src, lines_src, start_dst, lines_dst): self.start_src = start_src self.lines_src = lines_src self.start_dst = start_dst self.lines_dst = lines_dst self.variation = self.lines_dst - self.lines_src self.text = [] def __repr__(self): return '%s<(%d, %d) to (%d, %d)>' % ( self.__class__.__name__, self.start_src, self.lines_src, self.start_dst, self.lines_dst) class FilePatchDiff(FilePatchBase): """Patch for a single file.""" def __init__(self, filename, diff, svn_properties): super(FilePatchDiff, self).__init__(filename) if not diff: self._fail('File doesn\'t have a diff.') self.diff_header, self.diff_hunks = self._split_header(diff) self.svn_properties = svn_properties or [] self.is_git_diff = self._is_git_diff_header(self.diff_header) self.patchlevel = 0 if self.is_git_diff: self._verify_git_header() else: self._verify_svn_header() self.hunks = self._split_hunks() if self.source_filename and not self.is_new: self._fail('If source_filename is set, is_new must be also be set') def get(self, for_git): if for_git or not self.source_filename: return self.diff_header + self.diff_hunks else: # patch is stupid. It patches the source_filename instead so get rid of # any source_filename reference if needed. return ( self.diff_header.replace( self.source_filename_utf8, self.filename_utf8) + self.diff_hunks) def set_relpath(self, relpath): old_filename = self.filename_utf8 old_source_filename = self.source_filename_utf8 or self.filename_utf8 super(FilePatchDiff, self).set_relpath(relpath) # Update the header too. filename = self.filename_utf8 source_filename = self.source_filename_utf8 or self.filename_utf8 lines = self.diff_header.splitlines(True) for i, line in enumerate(lines): if line.startswith('diff --git'): lines[i] = line.replace( 'a/' + old_source_filename, source_filename).replace( 'b/' + old_filename, filename) elif re.match(r'^\w+ from .+$', line) or line.startswith('---'): lines[i] = line.replace(old_source_filename, source_filename) elif re.match(r'^\w+ to .+$', line) or line.startswith('+++'): lines[i] = line.replace(old_filename, filename) self.diff_header = ''.join(lines) def _split_header(self, diff): """Splits a diff in two: the header and the hunks.""" header = [] hunks = diff.splitlines(True) while hunks: header.append(hunks.pop(0)) if header[-1].startswith('--- '): break else: # Some diff may not have a ---/+++ set like a git rename with no change or # a svn diff with only property change. pass if hunks: if not hunks[0].startswith('+++ '): self._fail('Inconsistent header') header.append(hunks.pop(0)) if hunks: if not hunks[0].startswith('@@ '): self._fail('Inconsistent hunk header') # Mangle any \\ in the header to /. header_lines = ('Index:', 'diff', 'copy', 'rename', '+++', '---') basename = os.path.basename(self.filename_utf8) for i in xrange(len(header)): if (header[i].split(' ', 1)[0] in header_lines or header[i].endswith(basename)): header[i] = header[i].replace('\\', '/') return ''.join(header), ''.join(hunks) @staticmethod def _is_git_diff_header(diff_header): """Returns True if the diff for a single files was generated with git.""" # Delete: http://codereview.chromium.org/download/issue6368055_22_29.diff # Rename partial change: # http://codereview.chromium.org/download/issue6250123_3013_6010.diff # Rename no change: # http://codereview.chromium.org/download/issue6287022_3001_4010.diff return any(l.startswith('diff --git') for l in diff_header.splitlines()) def _split_hunks(self): """Splits the hunks and does verification.""" hunks = [] for line in self.diff_hunks.splitlines(True): if line.startswith('@@'): match = re.match(r'^@@ -([\d,]+) \+([\d,]+) @@.*$', line) # File add will result in "-0,0 +1" but file deletion will result in # "-1,N +0,0" where N is the number of lines deleted. That's from diff # and svn diff. git diff doesn't exhibit this behavior. # svn diff for a single line file rewrite "@@ -1 +1 @@". Fun. # "@@ -1 +1,N @@" is also valid where N is the length of the new file. if not match: self._fail('Hunk header is unparsable') count = match.group(1).count(',') if not count: start_src = int(match.group(1)) lines_src = 1 elif count == 1: start_src, lines_src = map(int, match.group(1).split(',', 1)) else: self._fail('Hunk header is malformed') count = match.group(2).count(',') if not count: start_dst = int(match.group(2)) lines_dst = 1 elif count == 1: start_dst, lines_dst = map(int, match.group(2).split(',', 1)) else: self._fail('Hunk header is malformed') new_hunk = Hunk(start_src, lines_src, start_dst, lines_dst) if hunks: if new_hunk.start_src <= hunks[-1].start_src: self._fail('Hunks source lines are not ordered') if new_hunk.start_dst <= hunks[-1].start_dst: self._fail('Hunks destination lines are not ordered') hunks.append(new_hunk) continue hunks[-1].text.append(line) if len(hunks) == 1: if hunks[0].start_src == 0 and hunks[0].lines_src == 0: self.is_new = True if hunks[0].start_dst == 0 and hunks[0].lines_dst == 0: self.is_delete = True if self.is_new and self.is_delete: self._fail('Hunk header is all 0') if not self.is_new and not self.is_delete: for hunk in hunks: variation = ( len([1 for i in hunk.text if i.startswith('+')]) - len([1 for i in hunk.text if i.startswith('-')])) if variation != hunk.variation: self._fail( 'Hunk header is incorrect: %d vs %d; %r' % ( variation, hunk.variation, hunk)) if not hunk.start_src: self._fail( 'Hunk header start line is incorrect: %d' % hunk.start_src) if not hunk.start_dst: self._fail( 'Hunk header start line is incorrect: %d' % hunk.start_dst) hunk.start_src -= 1 hunk.start_dst -= 1 if self.is_new and hunks: hunks[0].start_dst -= 1 if self.is_delete and hunks: hunks[0].start_src -= 1 return hunks def mangle(self, string): """Mangle a file path.""" return '/'.join(string.replace('\\', '/').split('/')[self.patchlevel:]) def _verify_git_header(self): """Sanity checks the header. Expects the following format: diff --git (|a/) (|b/) --- +++ Everything is optional except the diff --git line. """ lines = self.diff_header.splitlines() # Verify the diff --git line. old = None new = None while lines: match = re.match(r'^diff \-\-git (.*?) (.*)$', lines.pop(0)) if not match: continue if match.group(1).startswith('a/') and match.group(2).startswith('b/'): self.patchlevel = 1 old = self.mangle(match.group(1)) new = self.mangle(match.group(2)) # The rename is about the new file so the old file can be anything. if new not in (self.filename_utf8, 'dev/null'): self._fail('Unexpected git diff output name %s.' % new) if old == 'dev/null' and new == 'dev/null': self._fail('Unexpected /dev/null git diff.') break if not old or not new: self._fail('Unexpected git diff; couldn\'t find git header.') if old not in (self.filename_utf8, 'dev/null'): # Copy or rename. self.source_filename = old.decode('utf-8') self.is_new = True last_line = '' while lines: line = lines.pop(0) self._verify_git_header_process_line(lines, line, last_line) last_line = line # Cheap check to make sure the file name is at least mentioned in the # 'diff' header. That the only remaining invariant. if not self.filename_utf8 in self.diff_header: self._fail('Diff seems corrupted.') def _verify_git_header_process_line(self, lines, line, last_line): """Processes a single line of the header. Returns True if it should continue looping. Format is described to http://www.kernel.org/pub/software/scm/git/docs/git-diff.html """ match = re.match(r'^(rename|copy) from (.+)$', line) old = self.source_filename_utf8 or self.filename_utf8 if match: if old != match.group(2): self._fail('Unexpected git diff input name for line %s.' % line) if not lines or not lines[0].startswith('%s to ' % match.group(1)): self._fail( 'Confused %s from/to git diff for line %s.' % (match.group(1), line)) return match = re.match(r'^(rename|copy) to (.+)$', line) if match: if self.filename_utf8 != match.group(2): self._fail('Unexpected git diff output name for line %s.' % line) if not last_line.startswith('%s from ' % match.group(1)): self._fail( 'Confused %s from/to git diff for line %s.' % (match.group(1), line)) return match = re.match(r'^deleted file mode (\d{6})$', line) if match: # It is necessary to parse it because there may be no hunk, like when the # file was empty. self.is_delete = True return match = re.match(r'^new(| file) mode (\d{6})$', line) if match: mode = match.group(2) # Only look at owner ACL for executable. if bool(int(mode[4]) & 1): self.svn_properties.append(('svn:executable', '.')) elif not self.source_filename and self.is_new: # It's a new file, not from a rename/copy, then there's no property to # delete. self.svn_properties.append(('svn:executable', None)) return match = re.match(r'^--- (.*)$', line) if match: if last_line[:3] in ('---', '+++'): self._fail('--- and +++ are reversed') if match.group(1) == '/dev/null': self.is_new = True elif self.mangle(match.group(1)) != old: # git patches are always well formatted, do not allow random filenames. self._fail('Unexpected git diff: %s != %s.' % (old, match.group(1))) if not lines or not lines[0].startswith('+++'): self._fail('Missing git diff output name.') return match = re.match(r'^\+\+\+ (.*)$', line) if match: if not last_line.startswith('---'): self._fail('Unexpected git diff: --- not following +++.') if '/dev/null' == match.group(1): self.is_delete = True elif self.filename_utf8 != self.mangle(match.group(1)): self._fail( 'Unexpected git diff: %s != %s.' % (self.filename, match.group(1))) if lines: self._fail('Crap after +++') # We're done. return def _verify_svn_header(self): """Sanity checks the header. A svn diff can contain only property changes, in that case there will be no proper header. To make things worse, this property change header is localized. """ lines = self.diff_header.splitlines() last_line = '' while lines: line = lines.pop(0) self._verify_svn_header_process_line(lines, line, last_line) last_line = line # Cheap check to make sure the file name is at least mentioned in the # 'diff' header. That the only remaining invariant. if not self.filename_utf8 in self.diff_header: self._fail('Diff seems corrupted.') def _verify_svn_header_process_line(self, lines, line, last_line): """Processes a single line of the header. Returns True if it should continue looping. """ match = re.match(r'^--- ([^\t]+).*$', line) if match: if last_line[:3] in ('---', '+++'): self._fail('--- and +++ are reversed') if match.group(1) == '/dev/null': self.is_new = True elif self.mangle(match.group(1)) != self.filename_utf8: # guess the source filename. self.source_filename = match.group(1).decode('utf-8') self.is_new = True if not lines or not lines[0].startswith('+++'): self._fail('Nothing after header.') return match = re.match(r'^\+\+\+ ([^\t]+).*$', line) if match: if not last_line.startswith('---'): self._fail('Unexpected diff: --- not following +++.') if match.group(1) == '/dev/null': self.is_delete = True elif self.mangle(match.group(1)) != self.filename_utf8: self._fail('Unexpected diff: %s.' % match.group(1)) if lines: self._fail('Crap after +++') # We're done. return def dump(self): """Dumps itself in a verbose way to help diagnosing.""" return str(self) + '\n' + self.get(True) class PatchSet(object): """A list of FilePatch* objects.""" def __init__(self, patches): for p in patches: assert isinstance(p, FilePatchBase) def key(p): """Sort by ordering of application. File move are first. Deletes are last. """ # The bool is necessary because None < 'string' but the reverse is needed. return ( p.is_delete, # False is before True, so files *with* a source file will be first. not bool(p.source_filename), p.source_filename_utf8, p.filename_utf8) self.patches = sorted(patches, key=key) def set_relpath(self, relpath): """Used to offset the patch into a subdirectory.""" for patch in self.patches: patch.set_relpath(relpath) def __iter__(self): for patch in self.patches: yield patch def __getitem__(self, key): return self.patches[key] @property def filenames(self): return [p.filename for p in self.patches]