From d9e7a3d7deeaab48a1be778af75c2aa850f05a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Sz=C3=B6ll=C5=91si?= Date: Thu, 16 Nov 2017 23:20:57 +0100 Subject: [PATCH] Run recovery installer in chroot (#901) The recovery installer now has as few dependencies on the Android recovery system as possible. --- .../APKBUILD | 21 +++---- .../build_zip.sh | 18 +++--- .../pmos_chroot | 44 ++++++++++++++ .../pmos_install | 59 +++++++++---------- .../pmos_install_functions | 40 +++++-------- .../pmos_setpw | 15 ++--- .../update-binary | 22 ++++--- pmb/install/recovery.py | 4 +- test/static_code_analysis.sh | 3 +- 9 files changed, 131 insertions(+), 95 deletions(-) create mode 100644 aports/main/postmarketos-android-recovery-installer/pmos_chroot diff --git a/aports/main/postmarketos-android-recovery-installer/APKBUILD b/aports/main/postmarketos-android-recovery-installer/APKBUILD index 61f185d2..99251628 100644 --- a/aports/main/postmarketos-android-recovery-installer/APKBUILD +++ b/aports/main/postmarketos-android-recovery-installer/APKBUILD @@ -1,5 +1,5 @@ pkgname=postmarketos-android-recovery-installer -pkgver=0.0.7 +pkgver=0.1.0 pkgrel=0 pkgdesc="TWRP compatible postmarketOS installer script" url="https://github.com/postmarketOS" @@ -7,6 +7,7 @@ url="https://github.com/postmarketOS" depends="busybox-extras lddtree cryptsetup multipath-tools device-mapper parted util-linux zip e2fsprogs tar" source="build_zip.sh update-binary + pmos_chroot pmos_install pmos_install_functions pmos_setpw" @@ -16,18 +17,18 @@ license="GPL3" package() { install -Dm755 "$srcdir/build_zip.sh" \ "$pkgdir/sbin/build-recovery-zip" - mkdir -p "$pkgdir/var/lib/postmarketos-android-recovery-installer/META-INF/com/google/android/" install -Dm644 "$srcdir"/update-binary \ "$pkgdir/var/lib/postmarketos-android-recovery-installer/META-INF/com/google/android/update-binary" - mkdir "$pkgdir/var/lib/postmarketos-android-recovery-installer/bin/" + install -Dm755 "$srcdir"/pmos_chroot \ + "$pkgdir/var/lib/postmarketos-android-recovery-installer/pmos_chroot" for file in pmos_install pmos_install_functions pmos_setpw; do install -Dm755 "$srcdir/$file" \ - "$pkgdir/var/lib/postmarketos-android-recovery-installer/bin/$file" + "$pkgdir/var/lib/postmarketos-android-recovery-installer/chroot/bin/$file" done - mkdir "$pkgdir/var/lib/postmarketos-android-recovery-installer/lib/" } -sha512sums="5934797c1aec8b3f8650dd1a149000c1227552f768b5417eafebf2772da6b34579f3c96d9441053d152500b2f68f29ba0dccbabf6fd0191c924daffd01be6f89 build_zip.sh -6e658b6924c31deb55561c256eea842824d2d21fc90e4b8227c0c910153d3cf16dca86eab6a3dcdaeb36d625c34c1153f4858e6813df5f909d2f3445b3a6c710 update-binary -bc340a1a83673c7a66da09e44dc40b20305e5ef52dea3c9d8151d3c07064b7b2016d4fe99869bb9d725a5a3aeb0bd570af3e26a91c7da6905cd9f281a99adb4d pmos_install -fb9507a82d44c580af714488d18e7b59a1be0aa60292578e6571df7905c312caaaefd7d54eb6ce1a0b768616e356ece45b50c3e28acce86312d6d8e028bdf389 pmos_install_functions -27dd89aa8471349995a1cbbc1034ead662a0d1dd70ca5490f3191ceaaeb853331003c20ffddbbd95fe822135a85c1beb1e2a32bb33b10c2a4177b30347a40555 pmos_setpw" +sha512sums="f02e67d26f4f977c5098ff6eee51b53ec962982c41b8b33c1a206c218c483bd20f782c06622cf8d724a9a1cdb5b9cc1b76d3bf32e562c9b558747ca3f3408ffd build_zip.sh +7c396f4ae50f71d8c5ecf0528d1841639da75934dc8bd160311969e0d461dfc2f851eb6aa0373ec5cced11430ebc961f55a79863badb68d70fcad43725f9396b update-binary +4a049428862cbbf9eef6ee0f49ccefa6e51bfdfac5a48000fb5f199d8e09ef7c44219429b558ec7beaf6a86f84b6185d160f0eb3e921b979b122121c2fc0060e pmos_chroot +caafd0e6345e2082e4a2dc7169b1dedf11fd4423e72a2a2d33a6056cf2ecbed2ffa5c995491cbc0a62518623d3d2830d754c28cc4dc68db2c4a9224492409168 pmos_install +36d8ca5ae092f8de0a9e2658581d3d1f83483b5076446aebaf5e1ab377e49615c31b81c00a23bc74d569de12a73977291c9a73e4f19b2faa694d981010c3eb35 pmos_install_functions +558680cfeac4ab5e191cffa0f875e762b923fa281ba65cbe64da525710f82e6f7707cb9e346ee53fa50bb19afc567c331b005cd9c20d00ec3869819cadd992a4 pmos_setpw" diff --git a/aports/main/postmarketos-android-recovery-installer/build_zip.sh b/aports/main/postmarketos-android-recovery-installer/build_zip.sh index b06f8baf..acaee24a 100644 --- a/aports/main/postmarketos-android-recovery-installer/build_zip.sh +++ b/aports/main/postmarketos-android-recovery-installer/build_zip.sh @@ -1,7 +1,6 @@ -#!/bin/ash -# shellcheck shell=dash +#!/bin/sh -# Copyright 2017 Attila Szöllősi +# Copyright 2017 Attila Szollosi # # This file is part of postmarketos-android-recovery-installer. # @@ -20,6 +19,8 @@ set -e +DEVICE="$1" + # Copy files to the destination specified # $1: files # $2: destination @@ -40,14 +41,11 @@ check_whether_exists() fi } -# shellcheck disable=SC1091 -. ./install_options - -BINARIES="/bin/umount /sbin/cryptsetup /sbin/findfs /sbin/kpartx /sbin/mkfs.ext2 /sbin/mkfs.ext4 /usr/sbin/parted /usr/sbin/partprobe" +BINARIES="/bin/busybox /bin/umount /sbin/cryptsetup /sbin/findfs /sbin/kpartx /sbin/mkfs.ext2 /sbin/mkfs.ext4 \ + /usr/sbin/parted /usr/sbin/partprobe" # shellcheck disable=SC2086 LIBRARIES=$(lddtree -l $BINARIES | awk '/lib/ {print}' | sort -u) -copy_files "$BINARIES" bin/ -copy_files "$LIBRARIES" lib/ +copy_files "$BINARIES" chroot/bin/ +copy_files "$LIBRARIES" chroot/lib/ check_whether_exists rootfs.tar.gz -[ "$FLASH_BOOT" = "true" ] && check_whether_exists boot.img zip -0 -r "pmos-$DEVICE.zip" . diff --git a/aports/main/postmarketos-android-recovery-installer/pmos_chroot b/aports/main/postmarketos-android-recovery-installer/pmos_chroot new file mode 100644 index 00000000..e18ea2ee --- /dev/null +++ b/aports/main/postmarketos-android-recovery-installer/pmos_chroot @@ -0,0 +1,44 @@ +#!/sbin/sh +exec > /tmp/postmarketos/pmos_chroot.log 2>&1 +set -ex + +export CHROOT='/tmp/postmarketos/chroot' + +# Extract chroot +unzip -o "$ZIP" chroot/* -d /tmp/postmarketos + +# shellcheck source=/dev/null +. "$CHROOT"/install_options +if [ "$FDE" = 'true' ] +then + # Install password setting script + { + echo '#!/sbin/sh' + echo "chroot $CHROOT /bin/pmos_setpw" + } > /sbin/pmos_setpw + chmod 755 /sbin/pmos_setpw +fi + +# Mount pmos.zip so we can access it inside the chroot +{ umount "$CHROOT"/pmos.zip ; rm "$CHROOT"/pmos.zip ; } || : +touch "$CHROOT"/pmos.zip +mount --bind "$ZIP" "$CHROOT"/pmos.zip + +# Create copy of fstab file provided by the recovery +cp /etc/recovery.fstab "$CHROOT"/recovery.fstab || { + [ "$?" = '255' ] && echo 'recovery.fstab not found, continuing...' || exit "$?" ; } + +# Mount necessary filesystems for the chroot +for mountpoint in "/dev" "/proc" "/sys" +do + mkdir -p "$CHROOT""$mountpoint" + mount --bind "$mountpoint" "$CHROOT""$mountpoint" +done + +# Set permissions and start the installation script +chmod 755 "$CHROOT"/bin/* +chroot "$CHROOT" /bin/pmos_install || { + echo 'Installation script failed.' + echo 'Check /tmp/postmarketos/chroot/pmos.log for more information.' + exit 1 +} diff --git a/aports/main/postmarketos-android-recovery-installer/pmos_install b/aports/main/postmarketos-android-recovery-installer/pmos_install index ccc91bce..0a4edd49 100755 --- a/aports/main/postmarketos-android-recovery-installer/pmos_install +++ b/aports/main/postmarketos-android-recovery-installer/pmos_install @@ -1,7 +1,7 @@ -#!/sbin/ash -# shellcheck shell=dash +#!/bin/busybox ash +# shellcheck shell=sh -# Copyright 2017 Attila Szöllősi +# Copyright 2017 Attila Szollosi # # This file is part of postmarketos-android-recovery-installer. # @@ -18,14 +18,14 @@ # You should have received a copy of the GNU General Public License # along with postmarketos-android-recovery-installer. If not, see . -# shellcheck source=pmos_install_functions -. /tmp/postmarketos/bin/pmos_install_functions "$1" "$2" -# shellcheck source=/dev/null -. "$WORKING_DIR"/install_options - -exec > "$WORKING_DIR"/pmos.log 2>&1 +exec > /pmos.log 2>&1 set -ex +/bin/busybox --install /bin + +# shellcheck source=pmos_install_functions +. /bin/pmos_install_functions + ui_print " " ui_print " 8 " ui_print " 888 " @@ -52,13 +52,10 @@ ui_print " " ui_print "postmarketOS recovery installer " ui_print " " -ui_print "Entering working directory..." -cd "$WORKING_DIR" -ui_print "Extracting files..." -busybox unzip -o "$ZIP" -x rootfs.tar.gz -mkdir -p /lib -ui_print "Symlinking .so files to /lib/..." -ln -sf "$WORKING_DIR"/lib/* /lib/ +# Umount and close install partition if mounted/open +mountpoint -q /mnt/pmOS && umount -R /mnt/pmOS +[ -e /dev/mapper/pm_crypt ] && cryptsetup close pm_crypt + ui_print "Symlinking block devices..." ln -sf /dev/block/* /dev/ ui_print "Extracting partition table..." @@ -68,55 +65,55 @@ umount_install_partition ui_print "Creating partition table on $INSTALL_DEVICE..." # parted returns nonzero even when command executed successfully partition_install_device || : +ui_print "Creating mountpoint..." +mkdir -p /mnt/pmOS + if [ "$FDE" = "true" ] then - [ -e /dev/mapper/pm_crypt ] && cryptsetup close pm_crypt ui_print "Generating temporary keyfile with random data..." - busybox dd bs=512 count=4 if=/dev/urandom of="$WORKING_DIR"/lukskey + dd bs=512 count=4 if=/dev/urandom of=/lukskey ui_print "Initializing LUKS device..." - cryptsetup luksFormat --use-urandom -c "$CIPHER" -q "$ROOT_PARTITION" "$WORKING_DIR"/lukskey + cryptsetup luksFormat --use-urandom -c "$CIPHER" -q "$ROOT_PARTITION" /lukskey ui_print "Opening LUKS partition..." - cryptsetup luksOpen -d "$WORKING_DIR"/lukskey "$ROOT_PARTITION" pm_crypt + cryptsetup luksOpen -d /lukskey "$ROOT_PARTITION" pm_crypt ui_print "Formatting LUKS partition..." mkfs.ext4 -L 'pmOS_root' /dev/mapper/pm_crypt ui_print "Mounting LUKS partition..." - mount -t ext4 -rw /dev/mapper/pm_crypt /"$INSTALL_PARTITION" + mount -t ext4 -rw /dev/mapper/pm_crypt /mnt/pmOS else ui_print "Formatting root partition..." mkfs.ext4 -L 'pmOS_root' "$ROOT_PARTITION" ui_print "Mounting root partition..." - mount -t ext4 -rw "$ROOT_PARTITION" /"$INSTALL_PARTITION" + mount -t ext4 -rw "$ROOT_PARTITION" /mnt/pmOS fi ui_print "Formatting pmOS_boot..." mkfs.ext2 -q -L 'pmOS_boot' "$PMOS_BOOT" ui_print "Mounting pmOS_boot..." -mkdir /"$INSTALL_PARTITION"/boot -mount -t ext2 -rw "$PMOS_BOOT" /"$INSTALL_PARTITION"/boot || { +mkdir /mnt/pmOS/boot +mount -t ext2 -rw "$PMOS_BOOT" /mnt/pmOS/boot || { ui_print "Failed to format/mount ext2 partition." ui_print "Trying ext4..." mkfs.ext4 -L 'pmOS_boot' "$PMOS_BOOT" - mount -t ext4 -rw "$PMOS_BOOT" /"$INSTALL_PARTITION"/boot + mount -t ext4 -rw "$PMOS_BOOT" /mnt/pmOS/boot } ui_print "Installing rootfs..." -busybox unzip -p "$ZIP" rootfs.tar.gz | busybox tar -xz -C /"$INSTALL_PARTITION" +unzip -p pmos.zip rootfs.tar.gz | tar -xz -C /mnt/pmOS if [ "$FLASH_KERNEL" = "true" ] then if [ "$ISOREC" = "true" ] then ui_print "Flashing kernel..." - busybox dd if=/"$INSTALL_PARTITION"/boot/vmlinuz-"$FLAVOR" of="$KERNEL_PARTITION" + dd if=/mnt/pmOS/boot/vmlinuz-"$FLAVOR" of="$KERNEL_PARTITION" ui_print "Flashing initfs..." - busybox gunzip -c /"$INSTALL_PARTITION"/boot/initramfs-"$FLAVOR" | busybox lzop \ + gunzip -c /mnt/pmOS/boot/initramfs-"$FLAVOR" | lzop \ > "$INITFS_PARTITION" else ui_print "Flashing boot.img..." - busybox dd if=/"$INSTALL_PARTITION"/boot/boot.img-"$FLAVOR" of="$BOOT_PARTITION" + dd if=/mnt/pmOS/boot/boot.img-"$FLAVOR" of="$BOOT_PARTITION" fi fi if [ "$FDE" = "true" ] then - ui_print "Creating a symlink for password setting script in /sbin/..." - ln -sf "$WORKING_DIR"/bin/pmos_setpw /sbin/ ui_print "Do not forget to add a password to the LUKS partition!" ui_print "Run the command: pmos_setpw from the terminal/adb shell!" fi diff --git a/aports/main/postmarketos-android-recovery-installer/pmos_install_functions b/aports/main/postmarketos-android-recovery-installer/pmos_install_functions index 9ff4f821..6f09ca7b 100755 --- a/aports/main/postmarketos-android-recovery-installer/pmos_install_functions +++ b/aports/main/postmarketos-android-recovery-installer/pmos_install_functions @@ -1,7 +1,6 @@ -#!/sbin/ash -# shellcheck shell=dash +#!/bin/sh -# Copyright 2017 Attila Szöllősi +# Copyright 2017 Attila Szollosi # # This file is part of postmarketos-android-recovery-installer. # @@ -18,25 +17,17 @@ # You should have received a copy of the GNU General Public License # along with postmarketos-android-recovery-installer. If not, see . -export OUTFD=$1 -export ZIP=$2 -export WORKING_DIR="/tmp/postmarketos" -export PATH=$PATH:"$WORKING_DIR"/bin - -# Use findfs and umount from util-linux -# shellcheck disable=SC2139 -alias findfs="$WORKING_DIR/bin/findfs" -# shellcheck disable=SC2139 -alias umount="$WORKING_DIR/bin/umount" +export PATH="/bin" +export LD_LIBRARY_PATH="/lib" # shellcheck source=/dev/null -. "$WORKING_DIR"/install_options +. /install_options # taken from https://github.com/Debuffer-XDA/Gov-Tuner/blob/master/META-INF/com/google/android/update-binary # Copyright (c) 2016 - 2017 Debuffer ui_print() { - echo -n -e "ui_print $1\n" > /proc/self/fd/"$OUTFD" - echo -n -e "ui_print\n" > /proc/self/fd/"$OUTFD" + echo "ui_print $1" > /proc/self/fd/"$OUTFD" + echo "ui_print" > /proc/self/fd/"$OUTFD" } extract_partition_table() { @@ -44,10 +35,10 @@ extract_partition_table() { "system") _INSTALL_DEVICE=$(findfs PARTLABEL="$SYSTEM_PARTLABEL") || \ # We need to resolve symlinks, to make set_subpartitions() work. - _INSTALL_DEVICE=$(busybox readlink -fn "$(awk '/^\/system/ {print $3}' /etc/recovery.fstab)") + _INSTALL_DEVICE=$(readlink -fn "$(awk '/^\/system/ {print $3}' /recovery.fstab)") ;; "external_sd") - _INSTALL_DEVICE=$(busybox readlink -fn "$(awk '/^\/external_sd/ {print $4}' /etc/recovery.fstab)") + _INSTALL_DEVICE=$(readlink -fn "$(awk '/^\/external_sd/ {print $4}' /recovery.fstab)") ;; *) echo "No support for flashing $INSTALL_PARTITION." @@ -70,7 +61,7 @@ extract_partition_table() { INITFS_PARTITION=$(findfs PARTLABEL="$INITFS_PARTLABEL") else _BOOT_PARTITION=$(findfs PARTLABEL="boot") || \ - _BOOT_PARTITION=$(awk '/^\/boot/ {print $3}' /etc/recovery.fstab) + _BOOT_PARTITION=$(awk '/^\/boot/ {print $3}' /recovery.fstab) if [ ! -z "$_BOOT_PARTITION" ] then echo "boot partition found at $_BOOT_PARTITION" @@ -100,17 +91,16 @@ partition_install_device() { set_subpartitions() { export PMOS_BOOT - PMOS_BOOT=/dev/mapper/"$(busybox basename "$INSTALL_DEVICE")"p1 + PMOS_BOOT=/dev/mapper/"$(basename "$INSTALL_DEVICE")"p1 export ROOT_PARTITION - ROOT_PARTITION=/dev/mapper/"$(busybox basename "$INSTALL_DEVICE")"p2 + ROOT_PARTITION=/dev/mapper/"$(basename "$INSTALL_DEVICE")"p2 } umount_install_partition() { - if busybox mountpoint -q "/$INSTALL_PARTITION/" + if [ -n "$(awk '$1 == install_part' install_part="$INSTALL_DEVICE" /proc/mounts)" ] then - umount -R /"$INSTALL_PARTITION"/ + umount "$INSTALL_DEVICE" else - echo 'Continuing...' - return 0 + echo "$INSTALL_DEVICE is not mounted, continuing..." fi } diff --git a/aports/main/postmarketos-android-recovery-installer/pmos_setpw b/aports/main/postmarketos-android-recovery-installer/pmos_setpw index 453c736a..35571b34 100755 --- a/aports/main/postmarketos-android-recovery-installer/pmos_setpw +++ b/aports/main/postmarketos-android-recovery-installer/pmos_setpw @@ -1,7 +1,6 @@ -#!/sbin/ash -e -# shellcheck shell=dash +#!/bin/sh -# Copyright 2017 Attila Szöllősi +# Copyright 2017 Attila Szollosi # # This file is part of postmarketos-android-recovery-installer. # @@ -18,13 +17,15 @@ # You should have received a copy of the GNU General Public License # along with postmarketos-android-recovery-installer. If not, see . +set -e + # shellcheck source=pmos_install_functions -. /tmp/postmarketos/bin/pmos_install_functions +. /bin/pmos_install_functions extract_partition_table set_subpartitions -echo "Set the password of the encrypted rootfs!" -cryptsetup luksAddKey -d "$WORKING_DIR"/lukskey "$ROOT_PARTITION" +echo "Set the password of the encrypted rootfs." +cryptsetup luksAddKey -d /lukskey "$ROOT_PARTITION" # Remove temporary keyfile -cryptsetup luksRemoveKey "$ROOT_PARTITION" "$WORKING_DIR"/lukskey +cryptsetup luksRemoveKey "$ROOT_PARTITION" /lukskey echo "Successfully added key to the LUKS device." diff --git a/aports/main/postmarketos-android-recovery-installer/update-binary b/aports/main/postmarketos-android-recovery-installer/update-binary index 4c5724e4..7dcae0ff 100644 --- a/aports/main/postmarketos-android-recovery-installer/update-binary +++ b/aports/main/postmarketos-android-recovery-installer/update-binary @@ -1,7 +1,6 @@ -#!/sbin/ash -# shellcheck shell=dash +#!/sbin/sh -# Copyright 2017 Attila Szöllősi +# Copyright 2017 Attila Szollosi # # This file is part of postmarketos-android-recovery-installer. # @@ -18,15 +17,20 @@ # You should have received a copy of the GNU General Public License # along with postmarketos-android-recovery-installer. If not, see . +export OUTFD=$2 +export ZIP=$3 + # Print fail information -OUTFD=$2 fail_info() { - FAIL_MSG="Failed. Check /tmp/postmarketos/pmos.log for more info!" - echo -n -e "ui_print $FAIL_MSG\n" > /proc/self/fd/"$OUTFD" - echo -n -e "ui_print\n" > /proc/self/fd/"$OUTFD" + FAIL_MSG="Failed. Check /tmp/postmarketos/pmos_chroot.log for more info!" + echo "ui_print $FAIL_MSG" > /proc/self/fd/"$OUTFD" + echo "ui_print" > /proc/self/fd/"$OUTFD" } + # Create working directory mkdir -p /tmp/postmarketos/ + # Extract and start the installer script -busybox unzip -o "$3" "bin/pmos_install" "bin/pmos_install_functions" "install_options" -d /tmp/postmarketos/ -/tmp/postmarketos/bin/pmos_install "$2" "$3" || { fail_info ; exit 1 ; } +unzip -o "$ZIP" "pmos_chroot" -d /tmp/postmarketos/ +chmod 755 /tmp/postmarketos/pmos_chroot +/tmp/postmarketos/pmos_chroot || { fail_info ; exit 1 ; } diff --git a/pmb/install/recovery.py b/pmb/install/recovery.py index 7fbaf853..f2b2dd4f 100644 --- a/pmb/install/recovery.py +++ b/pmb/install/recovery.py @@ -64,7 +64,7 @@ def create_zip(args, suffix): commands = [ # Move config file from /tmp/ to zip root - ["mv", "/tmp/install_options", "install_options"], + ["mv", "/tmp/install_options", "chroot/install_options"], # Create tar archive of the rootfs ["tar", "-pcf", "rootfs.tar", "--exclude", "./home/" + args.user + "/*", "-C", rootfs, "."], @@ -72,6 +72,6 @@ def create_zip(args, suffix): ["tar", "-prf", "rootfs.tar", "-C", "/", "./etc/apk/keys"], # Compress with -1 for speed improvement ["gzip", "-f1", "rootfs.tar"], - ["build-recovery-zip"]] + ["build-recovery-zip", args.device]] for command in commands: pmb.chroot.root(args, command, suffix, working_dir=zip_root) diff --git a/test/static_code_analysis.sh b/test/static_code_analysis.sh index e5fc0210..fd376151 100755 --- a/test/static_code_analysis.sh +++ b/test/static_code_analysis.sh @@ -29,6 +29,7 @@ sh_files=" ./aports/main/postmarketos-mkinitfs/init_functions.sh ./aports/main/postmarketos-update-kernel/update-kernel.sh ./aports/main/postmarketos-android-recovery-installer/build_zip.sh + ./aports/main/postmarketos-android-recovery-installer/pmos_chroot ./aports/main/postmarketos-android-recovery-installer/pmos_install ./aports/main/postmarketos-android-recovery-installer/pmos_install_functions ./aports/main/postmarketos-android-recovery-installer/pmos_setpw @@ -40,7 +41,7 @@ sh_files=" for file in ${sh_files}; do echo "Test with shellcheck: $file" cd "$DIR/../$(dirname "$file")" - shellcheck -x "$(basename "$file")" + shellcheck -e SC1008 -x "$(basename "$file")" done # Python: flake8