#!/usr/bin/python3

'''hddbackup - harddisk backup utility'''

# Copyright (c) 2001 John-Paul Gignac
# Copyright (c) 2004-2026 Joachim Reichel
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

# pylint: disable=invalid-name

import os
import re
import secrets
import shlex
import subprocess
import sys
import tempfile
import time

cat = "/usr/bin/cat"
rm  = "/usr/bin/rm"
tar = "/usr/bin/tar"

gzip  = "/usr/bin/gzip"
bzip2 = "/usr/bin/bzip2"
xz    = "/usr/bin/xz"

gpg = "/usr/bin/gpg"
ssh = "/usr/bin/ssh"

data_dir = "/var/lib/hddbackup"

def verbose_log(verbose, operation):
    """Log a command or filesystem operation."""
    if verbose:
        print(operation, flush=True)

def tar_exclude(dumpdir, remotehost, s):
    """Convert an absolute path inside the dump directory to a tar member name."""
    if not os.path.isabs(s):
        return s
    if remotehost:
        if not os.path.isabs(dumpdir):
            return None
        root = os.path.normpath(dumpdir)
        path = os.path.normpath(s)
    else:
        root = os.path.realpath(dumpdir)
        path = os.path.realpath(s)
    if os.path.commonpath([root, path]) != root:
        return None
    relative = os.path.relpath(path, root)
    if relative == ".":
        return relative
    return "./" + relative

def remotize_cmd(remotehost, s):
    """Escape the string and prefix it with the ssh command and remote hostname."""
    if not remotehost:
        return s
    return ssh + " " + remotehost + " " + shlex.quote(s)

def remotize_filename(remotehost, s):
    """Prefix the string with the remote hostname and a colon."""
    if not remotehost:
        return s
    return remotehost + ":" + s

def call(verbose, cmd):
    """Run a shell command and treat failure in any pipeline stage as failure."""
    verbose_log(verbose, cmd)
    try:
        return subprocess.check_call(["/bin/bash", "-o", "pipefail", "-c", cmd]) == 0
    except subprocess.CalledProcessError as e:
        print(f"Error: execution failed: {e}")
        return False

def remove_file(verbose, remotehost, filename):
    """Remove a local or remote file."""
    cmd = rm + " -f " + shlex.quote(filename)
    return call(verbose, remotize_cmd(remotehost, cmd))

def default_file_mode():
    """Return the mode a newly created regular file would get with the current umask."""
    mask = os.umask(0)
    os.umask(mask)
    return 0o666 & ~mask

def chmod_default_file_mode(verbose, filename):
    """Set a file mode matching normal creation under the current umask."""
    mode = default_file_mode()
    verbose_log(verbose, f"chmod({filename!r}, {mode:o})")
    os.chmod(filename, mode)

def get_output(verbose, cmd):
    """Convenience wrapper around subprocess.check_output()."""
    verbose_log(verbose, shlex.join(cmd) if not isinstance(cmd, str) else cmd)
    try:
        return subprocess.check_output(cmd).decode("utf-8")
    except subprocess.CalledProcessError as e:
        print(f"Error: execution failed: {e}")
        return ""

def usage():
    """Usage."""
    print("hddbackup performs full or differential backups")
    print("")
    print("Usage: hddbackup level [options ...] directory")
    print("")
    print("    level                   indicates the backup level (0-9)")
    print("    directory               indicates the root of the directory tree")
    print("")
    print("Options:")
    print("    -H, --help              show this help message")
    print("    -V, --version           show version info")
    print("")
    print("    -c, --compress=TYPE     set compression to one of gz|bz2|xz|none")
    print("    -d, --dir=DIR           set directory for archive files")
    print("    -e, --exclude=FILE      exclude the specified file or directory")
    print("    -f, --exclude-from=FILE exclude files or directories listed in FILE")
    print("    -g, --gpg=KEY           encrypt data using given KEY")
    print("    -h, --host=HOST         specify the hostname for remote backup")
    print("    -l, --label=NAME        set the volume label")
    print("    -m, --cross-mp          cross mount points when dumping")
    print("    -q, --quiet             suppress output (except errors)")
    print("    -v, --verbose           print commands and filesystem changes")
    print("    -z, --zip-here          for remote backups, perform compression locally")

def main(): # pylint: disable=R0912,R0914,R0915
    """Parse the command-line arguments."""
    compress = "gz"
    dumpdir = ""
    resultdir = "."
    excludes = []
    exclude_froms = []
    gpgkey = ""
    remotehost = ""
    label = ""
    level = -1
    crossmp = False
    quiet = False
    verbose = False
    ziphere = False

    # Ignore script name, extract first and last argument, copy remaining arguments into args, and
    # split --foo=bar at the equal sign into args.
    args = []
    first = ""
    last = ""
    for index, arg in enumerate(sys.argv):
        if index == 0:
            continue
        if index == 1:
            first = arg
            continue
        if index == len(sys.argv)-1:
            last = arg
            continue
        if len(arg) < 5 or arg[0] != "-" or arg[1] != "-":
            args.append(arg)
            continue
        pos = arg.find("=")
        if pos == -1:
            args.append(arg)
            continue
        args.append(arg[:pos])
        args.append(arg[pos+1:])

    # Help and usage.
    if first in [ "-H", "--help"]:
        usage()
        sys.exit(0)
    if first in [  "-V", "--version" ]:
        print("hddbackup 2.0.2")
        sys.exit(0)

    # Level and directory.
    if not first or not last:
        usage()
        sys.exit(1)
    if not re.fullmatch("[0-9]", first):
        print(f"Error: invalid level '{first}'.")
        usage()
        sys.exit(1)
    level = int(first)
    dumpdir = last

    # Optional argumenets.
    i = 0
    while i < len(args):

        if args[i] in [ "-c" , "--compress" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            s = args[i+1]
            if s not in [ "gz", "bz2", "xz", "none" ]:
                print(f"Error: unknown compression format '{s}'.")
                sys.exit(1)
            compress = s
            i += 1
        elif args[i] in [ "-d", "--dir" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            resultdir = os.path.normpath(args[i+1])
            i += 1
        elif args[i] in [ "-e", "--exclude" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            s = args[i+1]
            if os.path.isabs(s):
                print(f"Error: exclusion path '{s}' must be relative to '{dumpdir}'.")
                sys.exit(1)
            if not s.startswith("./"):
                s = "./" + s
            excludes.append(s)
            i += 1
        elif args[i] in [ "-f", "--exclude-from" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            exclude_froms.append(args[i+1])
            i += 1
        elif args[i] in [ "-g", "--gpg" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            gpgkey = args[i+1]
            i += 1
        elif args[i] in [ "-h", "--host" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            s = args[i+1]
            if not s or s.startswith("-") or re.search("[^-._a-zA-Z0-9]", s):
                print(f"Error: invalid hostname '{s}'.")
                sys.exit(1)
            remotehost = s
            i += 1
        elif args[i] in [ "-l", "--label" ]:
            if i == len(args)-1:
                usage()
                sys.exit(1)
            s = args[i+1]
            if re.search("[^-._a-zA-Z0-9]", s):
                print(f"Error: invalid label '{s}'.")
                sys.exit(1)
            label = s
            i += 1
        elif args[i] in [ "-m", "--cross-mp" ]:
            crossmp = True
        elif args[i] in [ "-q", "--quiet" ]:
            quiet = True
        elif args[i] in [ "-v", "--verbose" ]:
            verbose = True
        elif args[i] in [ "-z", "--zip-here" ]:
            ziphere = True
        else:
            print(f"Error: invalid command-line argument '{args[i]}'.")
            sys.exit(1)

        i += 1

    # Normalize the source directory after all options have been parsed.
    if remotehost:
        dumpdir = os.path.normpath(dumpdir)
    else:
        dumpdir = os.path.realpath(dumpdir)

    # Local exclusion files are validated here because --host may follow --exclude-from.
    if not remotehost:
        for i, exclude_from in enumerate(exclude_froms):
            if not os.path.isfile(exclude_from):
                print(f"Error: file '{exclude_from}' is missing.")
                sys.exit(1)
            try:
                with open(exclude_from, "rb") as file:
                    if any(os.path.isabs(line) for line in file):
                        print(f"Error: file '{exclude_from}' contains an absolute exclusion path.")
                        sys.exit(1)
            except OSError as e:
                print(f"Error: unable to read file '{exclude_from}': {e}")
                sys.exit(1)
            exclude_froms[i] = os.path.abspath(exclude_from)

    # Create data_dir if it does not exist.
    if not os.path.isdir(data_dir):
        verbose_log(verbose, f"mkdir({data_dir!r})")
        os.mkdir(data_dir)

    # Figure out the hostname of the machine being backed up.
    if not remotehost:
        hostname = get_output(verbose, "/bin/hostname").strip()
        if not hostname:
            print("Error: failed to get the local hostname.")
            sys.exit(2)
    else:
        hostname = remotehost

    # Apply defaults for label.
    if not label:
        if dumpdir == "/":
            label = hostname
        else:
            print("Error: when dumping a directory other than '/', --label is required.")
            sys.exit(1)

    # Get date.
    backup_date = get_output(verbose, [ "/bin/date", "+%Y-%m-%d" ]).strip()
    if not backup_date:
        print("Error: failed to get the current date.")
        sys.exit(2)

    # Construct filename of tarball, exclude it from backup for local backups.
    resultname = label + "-" + backup_date + "-" + str(level) + ".tar"
    if compress != "none":
        resultname += "." + compress
    if gpgkey:
        resultname += ".gpg"
    if not remotehost:
        s = os.path.abspath(os.path.join(resultdir, resultname))
        excludes.append(tar_exclude(dumpdir, remotehost, s) or s)

    # Check dumpdir/label correspondence.
    data = data_dir + "/" + label
    if os.path.isfile(data + ".info"):
        info = get_output(verbose, [ "cat", data + ".info" ]).strip()
        stored_hostname, separator, stored_dumpdir = info.partition(" ")
        if remotehost:
            stored_dumpdir = os.path.normpath(stored_dumpdir)
        else:
            stored_dumpdir = os.path.realpath(stored_dumpdir)
        if (not separator or stored_hostname != hostname
                or stored_dumpdir != dumpdir):
            print(f"Error: the volume label '{label}' is already used for another directory tree.")
            print("Error: choose a unique label using the --label option.")
            sys.exit(1)
    else:
        cmd = "echo " + shlex.quote(hostname) + " " + shlex.quote(dumpdir)
        cmd += " > " + shlex.quote(data + ".info")
        if not call(verbose, cmd):
            print(f"Error: unable to create '{data}.info'.")
            sys.exit(2)

    # Locate the appropriate previous level.
    recentlevel = -1
    recentdate = -1
    i = 0
    while i < level:
        name = data + "-" + str(i) + ".snar"
        if not os.path.isfile(name):
            i += 1
            continue
        line = ""
        with open(name, "rb") as file:
            # First line "GNU", second line up to '\0' is the timestamp.
            tmp = file.readline().strip()
            if not tmp.startswith(b"GNU "):
                print(f"Error: unexpected file contents in '{name}'.")
                sys.exit(2)
            line = file.readline().strip().split(b"\x00")[0]
        try:
            snapshot_date = int(line)
        except ValueError:
            print(f"Error: unexpected file contents in '{name}'.")
            sys.exit(2)
        if snapshot_date > recentdate:
            recentlevel = i
            recentdate = snapshot_date
        i += 1

    recenttime = None
    if recentlevel >= 0:
        name = data + "-" + str(recentlevel) + ".snar"
        try:
            recenttime = time.localtime(recentdate)
        except (OverflowError, OSError, ValueError):
            print(f"Error: unexpected file contents in '{name}'.")
            sys.exit(2)

    # Compute snar file names.
    oldsnarfile = data + "-" + str(recentlevel) + ".snar"
    newsnarfile = data + "-" + str(level) + ".snar"
    tmpsnarfile = "/tmp/" + label + "-" + str(level) + "-"
    # Use random token to make collisions unlikely.
    tmpsnarfile += secrets.token_hex(16) + ".snar"
    excludes.append(tar_exclude(dumpdir, remotehost, tmpsnarfile) or tmpsnarfile)

    # Securely create the temporary snar file.
    destination = "(umask 077; set -C; : > " + shlex.quote(tmpsnarfile) + ")"
    cmd = remotize_cmd(remotehost, destination)
    if not call(verbose, cmd):
        s = remotize_filename(remotehost, tmpsnarfile)
        print(f"Error: unable to create '{s}'.")
        sys.exit(2)

    # Copy snar file to temporary location.
    if recentlevel >= 0:
        destination = cat + " > " + shlex.quote(tmpsnarfile)
        cmd = cat + " " + shlex.quote(oldsnarfile) + " | "
        cmd += remotize_cmd(remotehost, destination)
        if not call(verbose, cmd):
            remove_file(verbose, remotehost, tmpsnarfile)
            s = remotize_filename(remotehost, tmpsnarfile)
            print(f"Error: unable to copy '{oldsnarfile}' to '{s}'.")
            sys.exit(2)
        if not quiet:
            s = remotize_filename(remotehost, dumpdir)
            print(f"Dumping '{s}' as '{resultdir}/{resultname}' at level {level}, relative to "
                f"level {recentlevel}, dated "
                f"{recenttime.tm_year:04d}-{recenttime.tm_mon:02d}-{recenttime.tm_mday:02d} "
                f"{recenttime.tm_hour:02d}:{recenttime.tm_min:02d}:{recenttime.tm_sec:02d}.")
    else:
        if not quiet:
            s = remotize_filename(remotehost, dumpdir)
            print(f"Dumping '{s}' as '{resultdir}/{resultname}' at level {level}.")

    # Create the temporary output before synthesizing the tar command so it can be excluded.
    try:
        tmpfd, tmpresult = tempfile.mkstemp(prefix="." + resultname + ".", suffix=".tmp",
            dir=resultdir)
        os.close(tmpfd)
        verbose_log(verbose, f"mkstemp() -> {tmpresult!r}")
    except OSError as e:
        remove_file(verbose, remotehost, tmpsnarfile)
        print(f"Error: unable to create temporary archive in '{resultdir}': {e}")
        sys.exit(2)
    if not remotehost:
        tmproot = os.path.realpath(dumpdir)
        tmpmember = os.path.relpath(os.path.realpath(tmpresult), tmproot)
        if tmpmember != os.pardir and not tmpmember.startswith(os.pardir + os.sep):
            excludes.append("./" + tmpmember)

    # Synthesize tar command.
    cmd = "cd " + shlex.quote(dumpdir) + " && (" + tar + " -f - -c"
    if not crossmp:
        cmd += "l"
    if not ziphere:
        if compress == "gz":
            cmd += "z"
        elif compress == "bz2":
            cmd += "j"
        elif compress == "xz":
            cmd += "J"
        elif compress != "none":
            assert False
    cmd += " --listed-incremental=" + tmpsnarfile
    cmd += " --no-check-device --sparse"
    cmd += " --label=" + shlex.quote(label)
    cmd += "-" + shlex.quote(backup_date)
    cmd += "-" + shlex.quote(str(level))
    for exclude in excludes:
        cmd += " --exclude=" + shlex.quote(exclude)
    for exclude_from in exclude_froms:
        cmd += " --exclude-from=" + shlex.quote(exclude_from)
    cmd += " ."
    cmd += "; status=$?"
    cmd += "; if [ $status -eq 1 ]; then"
    cmd += " echo 'Warning: GNU tar completed with warnings.' >&2"
    cmd += "; exit 0; fi"
    cmd += "; exit $status)"
    cmd = "(" + remotize_cmd(remotehost, cmd) + ")"
    if ziphere:
        if compress == "gz":
            cmd += " | " + gzip
        elif compress == "bz2":
            cmd += " | " + bzip2
        elif compress == "xz":
            cmd += " | " + xz
        elif compress != "none":
            assert False
    if gpgkey:
        cmd += " | " + gpg + " --encrypt --recipient " + shlex.quote(gpgkey)

    cmd += " > " + shlex.quote(tmpresult)

    # Dump files.
    if not call(verbose, cmd):
        try:
            verbose_log(verbose, f"unlink({tmpresult!r})")
            os.unlink(tmpresult)
        except OSError:
            pass
        remove_file(verbose, remotehost, tmpsnarfile)
        s = remotize_filename(remotehost, dumpdir)
        print(f"Error: failed to dump '{s}'.")
        sys.exit(2)

    resultpath = os.path.join(resultdir, resultname)
    try:
        verbose_log(verbose, f"replace({tmpresult!r}, {resultpath!r})")
        os.replace(tmpresult, resultpath)
        chmod_default_file_mode(verbose, resultpath)
    except OSError as e:
        try:
            verbose_log(verbose, f"unlink({tmpresult!r})")
            os.unlink(tmpresult)
        except OSError:
            pass
        remove_file(verbose, remotehost, tmpsnarfile)
        print(f"Error: unable to publish archive '{resultpath}': {e}")
        sys.exit(2)

    # Copy snar file from temporary location.
    try:
        tmpsnarfd, tmpnewsnarfile = tempfile.mkstemp(
            prefix="." + os.path.basename(newsnarfile) + ".", suffix=".tmp",
            dir=os.path.dirname(newsnarfile))
        os.close(tmpsnarfd)
        verbose_log(verbose, f"mkstemp() -> {tmpnewsnarfile!r}")
    except OSError as e:
        remove_file(verbose, remotehost, tmpsnarfile)
        print(f"Error: unable to create temporary SNAR file: {e}")
        sys.exit(2)
    cmd = "(" + cat + " " + shlex.quote(tmpsnarfile)
    cmd += " && " + rm + " " + shlex.quote(tmpsnarfile) + ")"
    cmd = remotize_cmd(remotehost, cmd)
    cmd += " > " + shlex.quote(tmpnewsnarfile)
    if not call(verbose, cmd):
        try:
            verbose_log(verbose, f"unlink({tmpnewsnarfile!r})")
            os.unlink(tmpnewsnarfile)
        except OSError:
            pass
        remove_file(verbose, remotehost, tmpsnarfile)
        s = remotize_filename(remotehost, tmpsnarfile)
        print(f"Error: unable to copy '{s}' to '{newsnarfile}'.")
        sys.exit(2)
    try:
        verbose_log(verbose, f"replace({tmpnewsnarfile!r}, {newsnarfile!r})")
        os.replace(tmpnewsnarfile, newsnarfile)
        chmod_default_file_mode(verbose, newsnarfile)
    except OSError as e:
        try:
            verbose_log(verbose, f"unlink({tmpnewsnarfile!r})")
            os.unlink(tmpnewsnarfile)
        except OSError:
            pass
        print(f"Error: unable to publish SNAR file '{newsnarfile}': {e}")
        sys.exit(2)

    return sys.exit(0)

if __name__ == "__main__":
    main()
