# Copyright 2014 The Chromium OS Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """File type decoding class for Chromium OS rootfs file bucketing. This file decodes the type of file based on the contents, filename and other metadata. The result is a string that represents the file type and subtypes of the file, separated by slashes (/). The first level is one of the following: "text", "binary" and "inode". The first two refer to the contents of the file for regular files, while the third one is used for special files such as directories, symlinks, block devices, etc. The file type can have more than one level, for example "binary/elf/static", "binary/image/png", or "text/conf". See the filetype_unittest.py file for more examples. The purpose of this module is to provide a file type that splits the contents of a Chromium OS build in small buckets, partitioning cases where other standard classifications keep in the same set. """ from __future__ import print_function import itertools import magic import mmap import os import re import stat from chromite.lib import parseelf # The buffer size we would use to read files from the disk. FILE_BUFFER_SIZE = 32 * 1024 def SplitShebang(header): """Splits a shebang (#!) into command and arguments. Args: header: The first line of a shebang file, for example "#!/usr/bin/env -uPWD python foo.py\n". The referenced command must be an absolute path with optionally some arguments. Returns: A tuple of strings (command, args) where the first string is the called and the second is the list of arguments as passed in the header. Riases: ValueError if the passed header is not a valid shebang line. """ m = re.match(r'#!\s*(/[a-z/0-9\.-]+)\s*(.*)$', header) if m: return m.group(1), m.group(2).strip() raise ValueError('shebang (#!) line expected') class FileTypeDecoder(object): """Class to help decode the type of a file. This class implements a single GetType() method that decodes the type of a file based on the contents and metadata. This class holds some global data shared between several calls to that method. """ # Whitelist of mime types and their mapping to file type. MIME_TYPE_MAPPING = { 'application/x-gzip': 'binary/compressed/gzip', 'application/x-bzip2': 'binary/compressed/bzip2', 'application/x-xz': 'binary/compressed/xz', # Goobuntu magic database returns 'gzip' instead of 'x-gzip'. This # supports running dep_tracker outside the chroot for development. 'application/gzip': 'binary/compressed/gzip', } def __init__(self, root='/'): """Initializes the internal state. Args: root: Path to the root directory where all the files live. This will be assumed as the root directory for absolute symlinks. """ self._root = root self._mime = magic.open(magic.MIME_TYPE) self._mime.load() def __del__(self): self._mime.close() def GetType(self, rel_path, st=None, elf=None): """Return the file type of the passed file. Does a best-effort attempt to infer the file type of the passed file. If only rel_path is provided, the stat_struct information and parsed ELF data will be computed. If the information is already available, such as if the ELF file is already parsed, passing st and elf will speed up the file detection. Args: rel_path: The path to the file, used to detect the filetype from the contents of the file. st: The stat_result struct of the file. elf: The result of parseelf.ParseELF(). Returns: A string with the file type classified in categories separated by /. For example, a dynamic library will return 'binary/elf/dynamic-so'. If the type can't be inferred it returns None. """ # Analysis based on inode data. if st is None: st = os.lstat(os.path.join(self._root, rel_path)) if stat.S_ISDIR(st.st_mode): return 'inode/directory' if stat.S_ISLNK(st.st_mode): return 'inode/symlink' if not stat.S_ISREG(st.st_mode): return 'inode/special' if st.st_size == 0: return 'inode/empty' # Analysis based on the ELF header and contents. if elf: return self._GetELFType(elf) # Analysis based on the file contents. try: with open(os.path.join(self._root, rel_path), 'rb') as fobj: fmap = mmap.mmap(fobj.fileno(), 0, prot=mmap.PROT_READ) result = self._GetTypeFromContent(rel_path, fobj, fmap) fmap.close() return result except IOError: return def _GetTypeFromContent(self, rel_path, fobj, fmap): """Return the file path based on the file contents. This helper function detect the file type based on the contents of the file. Args: rel_path: The path to the file, used to detect the filetype from the contents of the file. fobj: a file() object for random access to rel_path. fmap: a mmap object mapping the whole rel_path file for reading. """ # Detect if the file is binary based on the presence of non-ASCII chars. We # include some the first 32 chars often used in text files but we exclude # the rest. ascii_chars = '\x07\x08\t\n\x0c\r\x1b' + ''.join(map(chr, range(32, 128))) is_binary = any(bool(chunk.translate(None, ascii_chars)) for chunk in iter(lambda: fmap.read(FILE_BUFFER_SIZE), '')) # We use the first part of the file in several checks. fmap.seek(0) first_kib = fmap.read(1024) # Binary files. if is_binary: # The elf argument was not passed, so compute it now if the file is an # ELF. if first_kib.startswith('\x7fELF'): return self._GetELFType(parseelf.ParseELF(self._root, rel_path, parse_symbols=False)) if first_kib.startswith('MZ\x90\0'): return 'binary/dos-bin' if len(first_kib) >= 512 and first_kib[510:512] == '\x55\xaa': return 'binary/bootsector/x86' # Firmware file depend on the technical details of the device they run on, # so there's no easy way to detect them. We use the filename to guess that # case. if '/firmware/' in rel_path and ( rel_path.endswith('.fw') or rel_path[-4:] in ('.bin', '.cis', '.csp', '.dsp')): return 'binary/firmware' # TZif (timezone) files. See tzfile(5) for details. if (first_kib.startswith('TZif' + '\0' * 16) or first_kib.startswith('TZif2' + '\0' * 15) or first_kib.startswith('TZif3' + '\0' * 15)): return 'binary/tzfile' # Whitelist some binary mime types. fobj.seek(0) # _mime.descriptor() will close the passed file descriptor. mime_type = self._mime.descriptor(os.dup(fobj.fileno())) if mime_type.startswith('image/'): return 'binary/' + mime_type if mime_type in self.MIME_TYPE_MAPPING: return self.MIME_TYPE_MAPPING[mime_type] # Other binary files. return 'binary' # Text files. # Read the first couple of lines used in the following checks. This will # only read the required lines, with the '\n' char at the end of each line # except on the last one if it is not present on that line. At this point # we know that the file is not empty, so at least one line existst. fmap.seek(0) first_lines = list(itertools.islice(iter(fmap.readline, ''), 0, 10)) head_line = first_lines[0] # #! or "shebangs". Only those files with a single line are considered # shebangs. Some files start with "#!" but are other kind of files, such # as python or bash scripts. try: prog_name, args = SplitShebang(head_line) if len(first_lines) == 1: return 'text/shebang' prog_name = os.path.basename(prog_name) args = args.split() if prog_name == 'env': # If "env" is called, we skip all the arguments passed to env (flags, # VAR=value) and treat the program name as the program to use. for i, arg in enumerate(args): if arg == '--' and (i + 1) < len(args): prog_name = args[i + 1] break if not arg or arg[0] == '-' or '=' in arg: continue prog_name = arg break # Strip the version number from comon programs like "python2.7". prog_name = prog_name.rstrip('0123456789-.') if prog_name in ('awk', 'bash', 'dash', 'ksh', 'perl', 'python', 'sh'): return 'text/script/' + prog_name # Other unknown script. return 'text/script' except ValueError: pass # PEM files. if head_line.strip() == '-----BEGIN CERTIFICATE-----': return 'text/pem/cert' if head_line.strip() == '-----BEGIN RSA PRIVATE KEY-----': return 'text/pem/rsa-private' # Linker script. if head_line.strip() == '/* GNU ld script': return 'text/ld-script' # Protobuf files. if rel_path.endswith('.proto'): return 'text/proto' if len(first_lines) == 1: if re.match(r'[0-9\.]+$', head_line): return 'text/oneline/number' return 'text/oneline' return 'text' @staticmethod def _GetELFType(elf): """Returns the file type for ELF files. Args: elf: The result of parseelf.ParseELF(). """ if elf['type'] == 'ET_REL': elf_type = 'object' elif (not '.dynamic' in elf['sections'] and not 'PT_DYNAMIC' in elf['segments']): elf_type = 'static' else: if elf['is_lib']: elf_type = 'dynamic-so' else: elf_type = 'dynamic-bin' return 'binary/elf/' + elf_type @classmethod def DecodeFile(cls, path): """Decodes the file type of the passed file. This function is a wrapper to the FileTypeDecoder class to decode the type of a single file. If you need to decode multiple files please use FileTypeDecoder class instead. Args: path: The path to the file or directory. Returns: A string with the decoded file type or None if it couldn't be decoded. """ return cls('.').GetType(path)