#!/usr/bin/env python3
"""
8Core Scanner -- privileged filesystem helper (B3 popravak)
(c) 2026 Tomislav Galic / 8Core

Zasto ovaj fajl postoji
------------------------
Prije ovog popravka, root-side bash skripte (file_reader.sh, scanner_worker.sh)
su provjeravale putanju jednom (realpath + prefix usporedba s dopustenom
bazom), a zatim -- odvojenim, kasnijim sistemskim pozivima (stat, sha256sum,
mv, rm, cat) -- ponovno pristupale ISTOJ putanji KAO TEKSTU. Vlasnik hosting
racuna ima potpunu kontrolu nad vlastitim stablom direktorija (/home/<acc>/...)
pa izmedju provjere i stvarne operacije moze zamijeniti bilo koju medjukomponentu
puta symlinkom. Root proces tada cita, brise ili premjesta sasvim drugu
datoteku nego onu koja je provjerena (TOCTOU -- time-of-check-to-time-of-use).

Ovaj helper to rjesava tako da se putanja NIKAD ne re-resolvira preko stringa:
svaka komponenta puta se otvara pojedinacno preko dir_fd + O_NOFOLLOW, pocevsi
od vec kanonikalizirane bazne putanje. Ako bilo koja komponenta -- ukljucujuci
finalnu -- jest symlink, otvaranje te komponente puca s ELOOP i cijela operacija
se prekida. Sve stvarne operacije (citanje, hash, stat, mv, unlink) rade se
ekskluzivno preko vec otvorenog file descriptora, nikad preko ponovno
sastavljenog path stringa, pa nema prozora za utrku.

Poziva se iskljucivo iz root-owned skripti (file_reader.sh, scanner_worker.sh)
koje su vec provjerile account/scope na visoj razini. Ovaj helper dodatno
neovisno provjerava da cilj lezi unutar zadane baze -- ne oslanja se na to da
je pozivatelj to vec ispravno napravio.

Izlaz je uvijek strojno parsabilan (KEY=VALUE po retku, binarni sadrzaj Base64),
nikad slobodni tekst, kako bash pozivatelj ne bi morao parsirati ambiguozan
izlaz.
"""
import base64
import ctypes
import errno
import hashlib
import os
import stat
import sys

_libc = ctypes.CDLL("libc.so.6", use_errno=True)
_RENAME_NOREPLACE = 1  # RENAME_NOREPLACE iz <linux/fs.h>


def _renameat2_noreplace(src_dir_fd: int, src_name: str, dst_dir_fd: int, dst_name: str):
    """
    Atomski rename preko dva vec verificirana dir_fd-a, s kernel-garantiranim
    RENAME_NOREPLACE: kernel sam, atomski, odbija ako odrediste vec postoji --
    nema odvojenog "provjeri pa premjesti" koraka koji bi se mogao utrkivati.
    Python-ov standardni os.rename(..., dir_fd=...) koristi stariji renameat()
    koji BEZ upozorenja prepisuje postojece odrediste, pa ovdje idemo izravno
    na glibc renameat2().
    """
    src_b = os.fsencode(src_name)
    dst_b = os.fsencode(dst_name)
    ret = _libc.renameat2(
        ctypes.c_int(src_dir_fd), ctypes.c_char_p(src_b),
        ctypes.c_int(dst_dir_fd), ctypes.c_char_p(dst_b),
        ctypes.c_uint(_RENAME_NOREPLACE),
    )
    if ret != 0:
        err = ctypes.get_errno()
        raise OSError(err, os.strerror(err))


class SecurityError(Exception):
    pass


def _split_relative(base_real: str, target_abs: str):
    if not target_abs.startswith("/"):
        raise SecurityError("TARGET_NOT_ABSOLUTE")
    if target_abs == base_real:
        raise SecurityError("TARGET_IS_BASE")
    prefix = base_real.rstrip("/") + "/"
    if not target_abs.startswith(prefix):
        raise SecurityError("TARGET_OUTSIDE_BASE")
    rel = target_abs[len(prefix):]
    parts = rel.split("/")
    for part in parts:
        if part in ("", ".", ".."):
            raise SecurityError("INVALID_PATH_SEGMENT")
    return parts


def open_beneath(base_dir: str, target_abs: str, final_flags: int):
    """
    Vraca (fd, parent_dir_fd, final_component_name) za `target_abs`, pod uvjetom
    da doista lezi striktno unutar `base_dir` i da NIJEDNA komponenta puta
    (ukljucujuci finalnu) nije symlink. parent_dir_fd ostaje otvoren -- pozivatelj
    ga koristi za operacije relativne na roditelja (unlinkat, renameat) i mora
    ga sam zatvoriti zajedno s fd.
    """
    base_real = os.path.realpath(base_dir)
    if not os.path.isdir(base_real):
        raise SecurityError("BASE_NOT_FOUND")

    parts = _split_relative(base_real, target_abs)

    base_fd = os.open(base_real, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    cur_fd = base_fd
    parent_fd = base_fd
    try:
        for idx, part in enumerate(parts):
            is_last = idx == len(parts) - 1
            try:
                if is_last:
                    new_fd = os.open(part, final_flags | os.O_NOFOLLOW, dir_fd=cur_fd)
                else:
                    new_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
                                      dir_fd=cur_fd)
            except OSError as e:
                if e.errno in (errno.ELOOP, errno.ENOTDIR) and not is_last:
                    raise SecurityError("SYMLINK_REJECTED")
                if e.errno == errno.ELOOP:
                    raise SecurityError("SYMLINK_REJECTED")
                if e.errno == errno.ENOENT:
                    raise SecurityError("PATH_NOT_FOUND")
                raise SecurityError("OPEN_FAILED:%d" % e.errno)

            st = os.fstat(new_fd)
            if not is_last:
                if not stat.S_ISDIR(st.st_mode):
                    os.close(new_fd)
                    raise SecurityError("INTERMEDIATE_NOT_DIR")
                if cur_fd != base_fd:
                    os.close(cur_fd)
                cur_fd = new_fd
                parent_fd = new_fd
            else:
                return new_fd, cur_fd, part
        raise SecurityError("EMPTY_PATH")
    except Exception:
        if cur_fd != base_fd:
            try:
                os.close(cur_fd)
            except OSError:
                pass
        try:
            os.close(base_fd)
        except OSError:
            pass
        raise


def open_parent_beneath(base_dir: str, target_abs: str):
    """
    Kao open_beneath, ali kada finalna komponenta JOS NE POSTOJI (npr. restore
    odrediste). Otvara i verificira sve roditeljske komponente preko dir_fd +
    O_NOFOLLOW, vraca (parent_fd, final_component_name) bez otvaranja finalne
    komponente. Pozivatelj potom mora koristiti *at() pozive (openat s O_CREAT|
    O_EXCL) relativne na parent_fd za samo stvaranje, nikad path string.
    """
    base_real = os.path.realpath(base_dir)
    if not os.path.isdir(base_real):
        raise SecurityError("BASE_NOT_FOUND")
    parts = _split_relative(base_real, target_abs)
    base_fd = os.open(base_real, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    cur_fd = base_fd
    try:
        for idx, part in enumerate(parts[:-1]):
            try:
                new_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
                                  dir_fd=cur_fd)
            except OSError as e:
                if e.errno in (errno.ELOOP, errno.ENOTDIR):
                    raise SecurityError("SYMLINK_REJECTED")
                if e.errno == errno.ENOENT:
                    raise SecurityError("PARENT_PATH_NOT_FOUND")
                raise SecurityError("OPEN_FAILED:%d" % e.errno)
            if cur_fd != base_fd:
                os.close(cur_fd)
            cur_fd = new_fd
        return cur_fd, parts[-1]
    except Exception:
        if cur_fd != base_fd:
            try:
                os.close(cur_fd)
            except OSError:
                pass
        try:
            os.close(base_fd)
        except OSError:
            pass
        raise


def cmd_read(args):
    base, target, max_bytes = args.base, args.target, args.max_bytes
    fd, parent_fd, _name = open_beneath(base, target, os.O_RDONLY)
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise SecurityError("NOT_REGULAR_FILE")
        size = st.st_size

        # Binarna detekcija na prvih 8192 bajta (neovisno o max_bytes), pa
        # nastavljamo citanje odakle je probe stao za hash cijele datoteke.
        probe = os.read(fd, 8192)
        is_binary = b"\x00" in probe

        hasher = hashlib.sha256()
        hasher.update(probe)
        chunk = os.read(fd, 65536)
        while chunk:
            hasher.update(chunk)
            chunk = os.read(fd, 65536)

        content_b64 = ""
        if not is_binary:
            os.lseek(fd, 0, os.SEEK_SET)
            head = os.read(fd, max_bytes)
            content_b64 = base64.b64encode(head).decode("ascii")

        print("PROTOCOL=8CORE_SAFE_FS_V1")
        print("SIZE=%d" % size)
        print("SHA256=%s" % hasher.hexdigest())
        print("BINARY=%d" % (1 if is_binary else 0))
        print("TRUNCATED=%d" % (1 if size > max_bytes else 0))
        print("CONTENT_B64=%s" % content_b64)
    finally:
        os.close(fd)
        if parent_fd != fd:
            try:
                os.close(parent_fd)
            except OSError:
                pass


def cmd_stat(args):
    fd, parent_fd, _name = open_beneath(args.base, args.target, os.O_RDONLY)
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise SecurityError("NOT_REGULAR_FILE")
        hasher = hashlib.sha256()
        chunk = os.read(fd, 65536)
        while chunk:
            hasher.update(chunk)
            chunk = os.read(fd, 65536)
        print("PROTOCOL=8CORE_SAFE_FS_V1")
        print("SIZE=%d" % st.st_size)
        print("SHA256=%s" % hasher.hexdigest())
        print("UID=%d" % st.st_uid)
        print("GID=%d" % st.st_gid)
        print("MODE=%o" % stat.S_IMODE(st.st_mode))
    finally:
        os.close(fd)
        if parent_fd != fd:
            try:
                os.close(parent_fd)
            except OSError:
                pass


def cmd_delete(args):
    """Brise samo regularnu datoteku, nikad direktorij, i samo nakon O_NOFOLLOW
    provjere cijelog puta preko dir_fd (unlinkat na verificiranom parent_fd)."""
    fd, parent_fd, name = open_beneath(args.base, args.target, os.O_RDONLY)
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise SecurityError("NOT_REGULAR_FILE")
    finally:
        os.close(fd)
    try:
        # unlinkat na parent_fd + ime -- unlink() po definiciji nikad ne slijedi
        # symlink (brise sam direktorijski unos, ne cilj), pa ovo ne otvara novi
        # TOCTOU prozor prema van iz roditeljskog direktorija.
        os.unlink(name, dir_fd=parent_fd)
    finally:
        try:
            os.close(parent_fd)
        except OSError:
            pass
    print("PROTOCOL=8CORE_SAFE_FS_V1")
    print("DELETED=1")


def cmd_move_to_quarantine(args):
    """
    Premjesta izvornu datoteku (mora vec postojati, unutar --base) u karantensko
    odrediste (mora JOS NE postojati, unutar --dest-base), koristeci
    renameat2(..., RENAME_NOREPLACE) preko verificiranih dir_fd-ova s obje
    strane -- nikad preko path stringa, i nikad preko postojeceg cilja.
    """
    src_fd, src_parent_fd, src_name = open_beneath(args.base, args.target, os.O_RDONLY)
    try:
        st = os.fstat(src_fd)
        if not stat.S_ISREG(st.st_mode):
            raise SecurityError("NOT_REGULAR_FILE")
    finally:
        os.close(src_fd)

    dest_parent_fd, dest_name = open_parent_beneath(args.dest_base, args.dest)
    try:
        try:
            _renameat2_noreplace(src_parent_fd, src_name, dest_parent_fd, dest_name)
        except OSError as e:
            if e.errno == errno.EEXIST:
                raise SecurityError("DEST_ALREADY_EXISTS")
            if e.errno == errno.EXDEV:
                raise SecurityError("CROSS_FILESYSTEM_NOT_SUPPORTED")
            raise SecurityError("RENAME_FAILED:%d" % e.errno)

        # Vlasnistvo/mod se postavljaju odmah, PREKO FD-a otvorenog s O_NOFOLLOW
        # relativno na vec verificirani dest_parent_fd -- ne preko path-based
        # chown/chmod u pozivajucoj bash skripti. Path-based chown/chmod NAKON
        # sto se subprocess vrati bio bi nov TOCTOU prozor: kad je odrediste
        # unutar account stabla (restore), vlasnik racuna moze u tom prozoru
        # zamijeniti upravo stvorenu datoteku symlinkom, a chown/chmod prati
        # symlinkove pa bi root promijenio vlasnistvo/mod proizvoljnog cilja.
        if args.set_owner is not None or args.set_mode is not None:
            dest_fd = os.open(dest_name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dest_parent_fd)
            try:
                dst = os.fstat(dest_fd)
                if not stat.S_ISREG(dst.st_mode):
                    raise SecurityError("DEST_NOT_REGULAR_FILE_AFTER_RENAME")
                if args.set_owner is not None:
                    uid_s, gid_s = args.set_owner.split(":", 1)
                    os.fchown(dest_fd, int(uid_s), int(gid_s))
                if args.set_mode is not None:
                    os.fchmod(dest_fd, int(args.set_mode, 8))
            finally:
                os.close(dest_fd)
    finally:
        try:
            os.close(dest_parent_fd)
        except OSError:
            pass
        try:
            os.close(src_parent_fd)
        except OSError:
            pass
    print("PROTOCOL=8CORE_SAFE_FS_V1")
    print("MOVED=1")


def cmd_restore(args):
    """
    Obrnuto od move_to_quarantine: izvor je u karanteni (--base), odrediste je
    u account stablu (--dest-base) i MORA biti novo (bez overwrite-a).
    """
    cmd_move_to_quarantine(args)


def main():
    import argparse

    parser = argparse.ArgumentParser(description="8Core Scanner safe filesystem helper")
    sub = parser.add_subparsers(dest="command", required=True)

    p_read = sub.add_parser("read")
    p_read.add_argument("--base", required=True)
    p_read.add_argument("--target", required=True)
    p_read.add_argument("--max-bytes", type=int, default=204800)
    p_read.set_defaults(func=cmd_read)

    p_stat = sub.add_parser("stat")
    p_stat.add_argument("--base", required=True)
    p_stat.add_argument("--target", required=True)
    p_stat.set_defaults(func=cmd_stat)

    p_del = sub.add_parser("delete")
    p_del.add_argument("--base", required=True)
    p_del.add_argument("--target", required=True)
    p_del.set_defaults(func=cmd_delete)

    p_quar = sub.add_parser("move-to-quarantine")
    p_quar.add_argument("--base", required=True)
    p_quar.add_argument("--target", required=True)
    p_quar.add_argument("--dest-base", required=True)
    p_quar.add_argument("--dest", required=True)
    p_quar.add_argument("--set-owner", default=None, help="UID:GID za fchown nakon uspjesnog premjestanja")
    p_quar.add_argument("--set-mode", default=None, help="Oktalni mod (npr. 640) za fchmod nakon uspjesnog premjestanja")
    p_quar.set_defaults(func=cmd_move_to_quarantine)

    p_restore = sub.add_parser("restore")
    p_restore.add_argument("--base", required=True)
    p_restore.add_argument("--target", required=True)
    p_restore.add_argument("--dest-base", required=True)
    p_restore.add_argument("--dest", required=True)
    p_restore.add_argument("--set-owner", default=None, help="UID:GID za fchown nakon uspjesnog restorea")
    p_restore.add_argument("--set-mode", default=None, help="Oktalni mod (npr. 640) za fchmod nakon uspjesnog restorea")
    p_restore.set_defaults(func=cmd_restore)

    args = parser.parse_args()
    try:
        args.func(args)
    except SecurityError as e:
        print("ERROR=%s" % str(e), file=sys.stderr)
        sys.exit(2)
    except OSError as e:
        print("ERROR=OS_ERROR:%d" % (e.errno or 0), file=sys.stderr)
        sys.exit(3)


if __name__ == "__main__":
    main()
