#!/bin/sh
# mm3-hk-export: export a private HyperKitty (Mailman 3) list archive to an mbox.
#
# Usage:  mm3-hk-export [-z|--gzip] <list@domain> [output]
#
# Output is byte-for-byte what the web "Download" button produces (same
# Email.as_bytes() code path). With -z/--gzip the stream is gzip-compressed,
# matching the web download's .mbox.gz exactly (and ".gz" is appended to the
# output name if absent). If the list has no archived messages (including the
# case where HyperKitty never created a MailingList row for it), nothing is
# written and the script exits 3, a distinct "nothing to archive" status rather
# than an error. A backup-and-destroy caller can then tell "backed up" from
# "nothing was backed up" and refuse to delete an uncaptured list.
#
# Runs the export through Debian's mailman-web wrapper, which executes the
# Django site as the correct web user. The mbox is created by that
# privilege-dropped user via tempfile.mkstemp (0600, race-free); this script,
# running as root, then copies it into a root-owned temp file on the
# destination filesystem and atomically renames it into place, so the published
# backup is root-owned and $out is never left partial.

set -eu

usage() {
    echo "usage: mm3-hk-export [-z|--gzip] <list@domain> [output]" >&2
}

GZIP=0
while [ $# -gt 0 ]; do
    case "$1" in
        -z|--gzip) GZIP=1; shift ;;
        -h|--help) usage; exit 0 ;;
        --)        shift; break ;;
        -*)        echo "mm3-hk-export: unknown option: $1" >&2; usage; exit 2 ;;
        *)         break ;;
    esac
done

if [ $# -lt 1 ]; then usage; exit 2; fi
addr="$1"
out="${2:-./${addr%@*}.mbox}"

if [ "$GZIP" -eq 1 ]; then
    gzip_py=True
    case "$out" in *.gz) ;; *) out="$out.gz" ;; esac
else
    gzip_py=False
fi

# Validate the address: this value is interpolated into a Python string
# literal below, so reject anything that isn't a plain localpart@domain.
if ! printf '%s' "$addr" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$'; then
    echo "mm3-hk-export: invalid list address: $addr" >&2
    exit 2
fi

# Best-effort: ask Mailman core for a list's archive_policy, to explain why a
# list has no HyperKitty archive. Reads it straight from core's own DB through
# "mailman-wrapper shell", whose Python namespace pre-binds getUtility() and
# IListManager, so there is no REST call, no credential handling, and no curl
# dependency. Echoes never|private|public on success, nothing on any failure;
# it must never abort the export. The address is interpolated into the Python
# snippet below, gated by the same regex that guards the export heredoc.
core_archive_policy() {
    command -v mailman-wrapper >/dev/null 2>&1 || return 0
    mailman-wrapper shell 2>/dev/null <<EOF | sed -n 's/^MM3HKPOL|//p'
try:
    ml = getUtility(IListManager).get("$1")
    print("MM3HKPOL|%s" % (ml.archive_policy.name if ml else "NOLIST"))
except Exception:
    pass
EOF
}

result=$(mailman-web shell <<EOF
import sys, os, tempfile, gzip, hashlib
from hyperkitty.models import MailingList
addr = "$addr"
GZIP = $gzip_py
try:
    ml = MailingList.objects.get(name=addr)
except MailingList.DoesNotExist:
    # No HyperKitty row at all: list is not archived (archive_policy=never)
    # or no message has ever been archived for it.
    print("MM3HK|NONE|0")
    sys.exit(0)
n = ml.emails.count()
if n == 0:
    # Row exists but holds no messages.
    print("MM3HK|EMPTY|0")
    sys.exit(0)
fd, path = tempfile.mkstemp(prefix="mm3hk-", suffix=".mbox", dir="/var/tmp")
# w+b so we can read the bytes back through this same fd (the original inode,
# immune to any /var/tmp name-swap) to hash exactly what we wrote.
raw = os.fdopen(fd, "w+b")
# mtime=0 keeps the gzip header deterministic (no timestamp), matching the
# web export, which uses a bare zlib gzip stream.
fh = gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) if GZIP else raw
try:
    for e in ml.emails.order_by("archived_date").iterator():
        fh.write(e.as_bytes())
        fh.write(b"\n\n")
finally:
    if GZIP:
        fh.close()  # flush the gzip trailer into raw (does not close raw)
    raw.flush()
# Hash the on-disk bytes via the held fd, then hand root the digest so it can
# verify the file wasn't swapped/truncated by another same-uid process before
# it copies it. (A fully-compromised exporter is out of scope, since it controls
# the source DB, but this closes same-uid swaps, partial writes, and corruption.)
h = hashlib.sha256()
raw.seek(0)
for chunk in iter(lambda: raw.read(1 << 16), b""):
    h.update(chunk)
raw.close()
print("MM3HK|FILE|%d|%s|%s" % (n, path, h.hexdigest()))
EOF
)

line=$(printf '%s\n' "$result" | grep '^MM3HK|' | tail -n1 || true)
if [ -z "$line" ]; then
    echo "mm3-hk-export: unexpected output from mailman-web shell:" >&2
    printf '%s\n' "$result" >&2
    exit 1
fi

status=$(printf '%s' "$line" | cut -d'|' -f2)
count=$(printf '%s' "$line" | cut -d'|' -f3)

case "$status" in
    NONE)
        policy=$(core_archive_policy "$addr")
        case "$policy" in
            never)
                echo "mm3-hk-export: $addr is set not to archive (archive_policy=never); nothing to export." >&2
                ;;
            private|public)
                echo "mm3-hk-export: $addr archives (archive_policy=$policy) but nothing has been archived yet; nothing written." >&2
                ;;
            *)
                echo "mm3-hk-export: $addr has no HyperKitty archive (archiving disabled, or nothing ever archived); nothing written." >&2
                ;;
        esac
        # Nothing archived: exit 3 (distinct from 0=archive-written) so a
        # backup-and-destroy caller can refuse to delete a list whose archive
        # was never captured. Not an error, just a normal recoverable outcome.
        exit 3
        ;;
    EMPTY)
        echo "mm3-hk-export: $addr has a HyperKitty archive but 0 messages; nothing written." >&2
        exit 3
        ;;
    FILE)
        path=$(printf '%s' "$line" | cut -d'|' -f4)
        sha=$(printf '%s' "$line" | cut -d'|' -f5)
        mkdir -p "$(dirname "$out")"
        # Publish the web-user temp file as root without re-resolving its path.
        # The export lives in /var/tmp owned by the web user, so any process
        # with that uid could swap the name for a symlink between a name-based
        # check and a name-based copy (TOCTOU) and make root copy an arbitrary
        # root-readable file. So: open the source ONCE with O_NOFOLLOW (a swapped
        # symlink makes the open fail outright), confirm it's a regular file via
        # the open fd, copy from that fd into a fresh root-owned temp in the
        # destination dir while hashing, verify the SHA-256 matches what the
        # exporter reported (rejects a regular-file swap, truncation, or partial
        # write by another same-uid process), fsync, and atomically rename into
        # place. Paths + digest are passed as argv (heredoc is single-quoted) so
        # nothing is interpolated.
        if ! python3 - "$path" "$out" "$sha" <<'PY'
import os, sys, stat, tempfile, hashlib
src, out, expected = sys.argv[1], sys.argv[2], sys.argv[3]
try:
    fd = os.open(src, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as e:
    sys.exit("mm3-hk-export: cannot open export temp (symlink swapped in?): %s" % e)
dtmp = None
try:
    if not stat.S_ISREG(os.fstat(fd).st_mode):
        sys.exit("mm3-hk-export: export temp is not a regular file")
    dfd, dtmp = tempfile.mkstemp(prefix=".mm3hk.", dir=os.path.dirname(out) or ".")
    h = hashlib.sha256()
    r = os.fdopen(fd, "rb"); fd = -1      # fd now owned by r
    with r, os.fdopen(dfd, "wb") as w:    # dfd now owned by w
        for chunk in iter(lambda: r.read(1 << 16), b""):
            h.update(chunk)
            w.write(chunk)
        w.flush()
        os.fsync(w.fileno())
    if not expected or h.hexdigest() != expected:
        os.unlink(dtmp); dtmp = None
        sys.exit("mm3-hk-export: export integrity check failed (file changed under us); not published")
    os.replace(dtmp, out)                 # atomic same-dir rename, root-owned, 0600
    dtmp = None                           # consumed by the rename
    # fsync the containing directory so the new directory entry is crash-durable
    # before we report success: a caller that then destroys the list must not be
    # able to lose the backup to a crash that drops the unsynced rename.
    dirfd = os.open(os.path.dirname(out) or ".", os.O_RDONLY | os.O_DIRECTORY)
    try:
        os.fsync(dirfd)
    finally:
        os.close(dirfd)
except BaseException:
    if dtmp is not None:
        try: os.unlink(dtmp)
        except OSError: pass
    raise
finally:
    if fd != -1:
        try: os.close(fd)
        except OSError: pass
    try: os.unlink(src)
    except OSError: pass
PY
        then
            echo "mm3-hk-export: failed to publish export to $out" >&2
            exit 1
        fi
        echo "mm3-hk-export: wrote $count messages to $out" >&2
        ;;
    *)
        echo "mm3-hk-export: unexpected status '$status'" >&2
        exit 1
        ;;
esac
