tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

download-test-fonts.py (8869B)


      1 #!/usr/bin/env python3
      2 
      3 """Download test fonts used by the FreeType regression test programs.  These
      4 will be copied to $FREETYPE/tests/data/ by default."""
      5 
      6 import argparse
      7 import collections
      8 import hashlib
      9 import io
     10 import os
     11 import requests
     12 import sys
     13 import zipfile
     14 
     15 from typing import Callable, List, Optional, Tuple
     16 
     17 # The list of download items describing the font files to install.  Each
     18 # download item is a dictionary with one of the following schemas:
     19 #
     20 # - File item:
     21 #
     22 #      file_url
     23 #        Type: URL string.
     24 #        Required: Yes.
     25 #        Description: URL to download the file from.
     26 #
     27 #      install_name
     28 #        Type: file name string
     29 #        Required: No
     30 #        Description: Installation name for the font file, only provided if
     31 #          it must be different from the original URL's basename.
     32 #
     33 #      hex_digest
     34 #        Type: hexadecimal string
     35 #        Required: No
     36 #        Description: Digest of the input font file.
     37 #
     38 # - Zip items:
     39 #
     40 #   These items correspond to one or more font files that are embedded in a
     41 #   remote zip archive.  Each entry has the following fields:
     42 #
     43 #      zip_url
     44 #        Type: URL string.
     45 #        Required: Yes.
     46 #        Description: URL to download the zip archive from.
     47 #
     48 #      zip_files
     49 #        Type: List of file entries (see below)
     50 #        Required: Yes
     51 #        Description: A list of entries describing a single font file to be
     52 #          extracted from the archive
     53 #
     54 # Apart from that, some schemas are used for dictionaries used inside
     55 # download items:
     56 #
     57 # - File entries:
     58 #
     59 #   These are dictionaries describing a single font file to extract from an
     60 #   archive.
     61 #
     62 #      filename
     63 #        Type: file path string
     64 #        Required: Yes
     65 #        Description: Path of source file, relative to the archive's
     66 #          top-level directory.
     67 #
     68 #      install_name
     69 #        Type: file name string
     70 #        Required: No
     71 #        Description: Installation name for the font file; only provided if
     72 #          it must be different from the original filename value.
     73 #
     74 #      hex_digest
     75 #        Type: hexadecimal string
     76 #        Required: No
     77 #        Description: Digest of the input source file
     78 #
     79 _DOWNLOAD_ITEMS = [
     80    {
     81        "zip_url": "https://github.com/python-pillow/Pillow/files/6622147/As.I.Lay.Dying.zip",
     82        "zip_files": [
     83            {
     84                "filename": "As I Lay Dying.ttf",
     85                "install_name": "As.I.Lay.Dying.ttf",
     86                "hex_digest": "ef146bbc2673b387",
     87            },
     88        ],
     89    },
     90 ]
     91 
     92 
     93 def digest_data(data: bytes):
     94    """Compute the digest of a given input byte string, which are the first
     95    8 bytes of its sha256 hash."""
     96    m = hashlib.sha256()
     97    m.update(data)
     98    return m.digest()[:8]
     99 
    100 
    101 def check_existing(path: str, hex_digest: str):
    102    """Return True if |path| exists and matches |hex_digest|."""
    103    if not os.path.exists(path) or hex_digest is None:
    104        return False
    105 
    106    with open(path, "rb") as f:
    107        existing_content = f.read()
    108 
    109    return bytes.fromhex(hex_digest) == digest_data(existing_content)
    110 
    111 
    112 def install_file(content: bytes, dest_path: str):
    113    """Write a byte string to a given destination file.
    114 
    115    Args:
    116      content: Input data, as a byte string
    117      dest_path: Installation path
    118    """
    119    parent_path = os.path.dirname(dest_path)
    120    if not os.path.exists(parent_path):
    121        os.makedirs(parent_path)
    122 
    123    with open(dest_path, "wb") as f:
    124        f.write(content)
    125 
    126 
    127 def download_file(url: str, expected_digest: Optional[bytes] = None):
    128    """Download a file from a given URL.
    129 
    130    Args:
    131      url: Input URL
    132      expected_digest: Optional digest of the file
    133        as a byte string
    134    Returns:
    135      URL content as binary string.
    136    """
    137    r = requests.get(url, allow_redirects=True)
    138    content = r.content
    139    if expected_digest is not None:
    140        digest = digest_data(r.content)
    141        if digest != expected_digest:
    142            raise ValueError(
    143                "%s has invalid digest %s (expected %s)"
    144                % (url, digest.hex(), expected_digest.hex())
    145            )
    146 
    147    return content
    148 
    149 
    150 def extract_file_from_zip_archive(
    151    archive: zipfile.ZipFile,
    152    archive_name: str,
    153    filepath: str,
    154    expected_digest: Optional[bytes] = None,
    155 ):
    156    """Extract a file from a given zipfile.ZipFile archive.
    157 
    158    Args:
    159      archive: Input ZipFile objec.
    160      archive_name: Archive name or URL, only used to generate a
    161        human-readable error message.
    162 
    163      filepath: Input filepath in archive.
    164      expected_digest: Optional digest for the file.
    165    Returns:
    166      A new File instance corresponding to the extract file.
    167    Raises:
    168      ValueError if expected_digest is not None and does not match the
    169      extracted file.
    170    """
    171    file = archive.open(filepath)
    172    if expected_digest is not None:
    173        digest = digest_data(archive.open(filepath).read())
    174        if digest != expected_digest:
    175            raise ValueError(
    176                "%s in zip archive at %s has invalid digest %s (expected %s)"
    177                % (filepath, archive_name, digest.hex(), expected_digest.hex())
    178            )
    179    return file.read()
    180 
    181 
    182 def _get_and_install_file(
    183    install_path: str,
    184    hex_digest: Optional[str],
    185    force_download: bool,
    186    get_content: Callable[[], bytes],
    187 ) -> bool:
    188    if not force_download and hex_digest is not None \
    189      and os.path.exists(install_path):
    190        with open(install_path, "rb") as f:
    191            content: bytes = f.read()
    192        if bytes.fromhex(hex_digest) == digest_data(content):
    193            return False
    194 
    195    content = get_content()
    196    install_file(content, install_path)
    197    return True
    198 
    199 
    200 def download_and_install_item(
    201    item: dict, install_dir: str, force_download: bool
    202 ) -> List[Tuple[str, bool]]:
    203    """Download and install one item.
    204 
    205    Args:
    206      item: Download item as a dictionary, see above for schema.
    207      install_dir: Installation directory.
    208      force_download: Set to True to force download and installation, even
    209        if the font file is already installed with the right content.
    210 
    211    Returns:
    212      A list of (install_name, status) tuples, where 'install_name' is the
    213      file's installation name under 'install_dir', and 'status' is a
    214      boolean that is True to indicate that the file was downloaded and
    215      installed, or False to indicate that the file is already installed
    216      with the right content.
    217    """
    218    if "file_url" in item:
    219        file_url = item["file_url"]
    220        install_name = item.get("install_name", os.path.basename(file_url))
    221        install_path = os.path.join(install_dir, install_name)
    222        hex_digest = item.get("hex_digest")
    223 
    224        def get_content():
    225            return download_file(file_url, hex_digest)
    226 
    227        status = _get_and_install_file(
    228            install_path, hex_digest, force_download, get_content
    229        )
    230        return [(install_name, status)]
    231 
    232    if "zip_url" in item:
    233        # One or more files from a zip archive.
    234        archive_url = item["zip_url"]
    235        archive = zipfile.ZipFile(io.BytesIO(download_file(archive_url)))
    236 
    237        result = []
    238        for f in item["zip_files"]:
    239            filename = f["filename"]
    240            install_name = f.get("install_name", filename)
    241            hex_digest = f.get("hex_digest")
    242 
    243            def get_content():
    244                return extract_file_from_zip_archive(
    245                    archive,
    246                    archive_url,
    247                    filename,
    248                    bytes.fromhex(hex_digest) if hex_digest else None,
    249                )
    250 
    251            status = _get_and_install_file(
    252                os.path.join(install_dir, install_name),
    253                hex_digest,
    254                force_download,
    255                get_content,
    256            )
    257            result.append((install_name, status))
    258 
    259        return result
    260 
    261    else:
    262        raise ValueError("Unknown download item schema: %s" % item)
    263 
    264 
    265 def main():
    266    parser = argparse.ArgumentParser(description=__doc__)
    267 
    268    # Assume this script is under tests/scripts/ and tests/data/
    269    # is the default installation directory.
    270    install_dir = os.path.normpath(
    271        os.path.join(os.path.dirname(__file__), "..", "data")
    272    )
    273 
    274    parser.add_argument(
    275        "--force",
    276        action="store_true",
    277        default=False,
    278        help="Force download and installation of font files",
    279    )
    280 
    281    parser.add_argument(
    282        "--install-dir",
    283        default=install_dir,
    284        help="Specify installation directory [%s]" % install_dir,
    285    )
    286 
    287    args = parser.parse_args()
    288 
    289    for item in _DOWNLOAD_ITEMS:
    290        for install_name, status in download_and_install_item(
    291            item, args.install_dir, args.force
    292        ):
    293            print("%s %s" % (install_name,
    294                             "INSTALLED" if status else "UP-TO-DATE"))
    295 
    296    return 0
    297 
    298 
    299 if __name__ == "__main__":
    300    sys.exit(main())
    301 
    302 # EOF