// SPDX-License-Identifier: GPL-2.0-only
/*
 *  ppm/tests/test_archive.c
 *
 *  Builds a tiny tar.gz fixture on the fly, extracts it through
 *  ppm_archive_extract, and asserts the result. Also exercises the
 *  rejection paths: absolute paths and "..".
 *
 *  Copyright (C) 2026 Project Tick
 */

#include <ppm/archive.h>

#include <archive.h>
#include <archive_entry.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

#include "util/path.h"

#define CHECK(expr) do {                                            \
    if (!(expr)) {                                                  \
        fprintf(stderr, "FAIL: %s:%d: %s\n", __FILE__, __LINE__, #expr); \
        return 1;                                                   \
    }                                                               \
} while (0)

static int build_tarball(const char *path, const char *entry_name,
                         const char *content) {
    struct archive *a = archive_write_new();
    if (!a) return -1;
    /* libzstd is great, but we use gzip here because it is always present
     * in libarchive and produces deterministic-enough fixtures. */
    archive_write_add_filter_gzip(a);
    archive_write_set_format_pax_restricted(a);
    if (archive_write_open_filename(a, path) != ARCHIVE_OK) {
        archive_write_free(a);
        return -1;
    }
    struct archive_entry *e = archive_entry_new();
    archive_entry_set_pathname(e, entry_name);
    archive_entry_set_size(e, (la_int64_t)strlen(content));
    archive_entry_set_filetype(e, AE_IFREG);
    archive_entry_set_perm(e, 0644);
    archive_write_header(a, e);
    archive_write_data(a, content, strlen(content));
    archive_entry_free(e);
    archive_write_close(a);
    archive_write_free(a);
    return 0;
}

/* Does this libarchive build support *writing* zstd-compressed archives?
 * Read support is near-universal; write support is not. */
static int zstd_write_supported(void) {
    struct archive *a = archive_write_new();
    if (!a) return 0;
    int ok = (archive_write_add_filter_zstd(a) == ARCHIVE_OK);
    archive_write_free(a);
    return ok;
}

/* Like build_tarball() but emits a zstd-compressed tarball. Caller must
 * have confirmed zstd write support first. */
static int build_zstd_tarball(const char *path, const char *entry_name,
                              const char *content) {
    struct archive *a = archive_write_new();
    if (!a) return -1;
    if (archive_write_add_filter_zstd(a) != ARCHIVE_OK) {
        archive_write_free(a);
        return -1;
    }
    archive_write_set_format_pax_restricted(a);
    if (archive_write_open_filename(a, path) != ARCHIVE_OK) {
        archive_write_free(a);
        return -1;
    }
    struct archive_entry *e = archive_entry_new();
    archive_entry_set_pathname(e, entry_name);
    archive_entry_set_size(e, (la_int64_t)strlen(content));
    archive_entry_set_filetype(e, AE_IFREG);
    archive_entry_set_perm(e, 0644);
    archive_write_header(a, e);
    archive_write_data(a, content, strlen(content));
    archive_entry_free(e);
    archive_write_close(a);
    archive_write_free(a);
    return 0;
}

/*
 * Build a malicious tarball with two entries:
 *   1. a symlink `linkname` -> `target` (a location outside dest), and
 *   2. a regular file `filename` whose path leads through that symlink.
 * A vulnerable extractor would follow the symlink and write the file
 * outside the destination tree.
 */
static int build_symlink_escape_tarball(const char *path, const char *linkname,
                                        const char *target,
                                        const char *filename,
                                        const char *content) {
    struct archive *a = archive_write_new();
    if (!a) return -1;
    archive_write_add_filter_gzip(a);
    archive_write_set_format_pax_restricted(a);
    if (archive_write_open_filename(a, path) != ARCHIVE_OK) {
        archive_write_free(a);
        return -1;
    }

    /* Entry 1: the symlink. */
    struct archive_entry *e = archive_entry_new();
    archive_entry_set_pathname(e, linkname);
    archive_entry_set_filetype(e, AE_IFLNK);
    archive_entry_set_symlink(e, target);
    archive_entry_set_perm(e, 0777);
    archive_write_header(a, e);
    archive_entry_free(e);

    /* Entry 2: a file written "through" the symlink. */
    e = archive_entry_new();
    archive_entry_set_pathname(e, filename);
    archive_entry_set_size(e, (la_int64_t)strlen(content));
    archive_entry_set_filetype(e, AE_IFREG);
    archive_entry_set_perm(e, 0644);
    archive_write_header(a, e);
    archive_write_data(a, content, strlen(content));
    archive_entry_free(e);

    archive_write_close(a);
    archive_write_free(a);
    return 0;
}

static int path_exists(const char *p) {
    struct stat st;
    return lstat(p, &st) == 0;
}

int main(void) {
    char tpl[] = "/tmp/ppm-arc-XXXXXX";
    char *root = mkdtemp(tpl);
    CHECK(root != NULL);

    /* --- good tarball ---------------------------------------------------- */
    char *tarpath;
    CHECK(ppm_path_join(root, "good.tar.gz", &tarpath) == PPM_OK);
    CHECK(build_tarball(tarpath, "payload/hello.txt", "world") == 0);

    char *destdir;
    CHECK(ppm_path_join(root, "out", &destdir) == PPM_OK);
    CHECK(ppm_path_mkdirs(destdir, 0755) == PPM_OK);

    CHECK(ppm_archive_extract(tarpath, destdir) == PPM_OK);

    char *extracted;
    CHECK(ppm_path_join(destdir, "payload/hello.txt", &extracted) == PPM_OK);
    FILE *f = fopen(extracted, "r");
    CHECK(f != NULL);
    char buf[16] = {0};
    size_t n = fread(buf, 1, sizeof(buf) - 1, f);
    fclose(f);
    CHECK(n == 5 && strcmp(buf, "world") == 0);

    /* --- non-empty destination rejected --------------------------------- */
    /* destdir already has contents from the prior extraction. */
    CHECK(ppm_archive_extract(tarpath, destdir) == PPM_ERR_EXISTS);

    /* --- malicious entry: absolute path --------------------------------- */
    char *evilpath, *evildest;
    CHECK(ppm_path_join(root, "evil.tar.gz", &evilpath) == PPM_OK);
    CHECK(ppm_path_join(root, "evilout",     &evildest) == PPM_OK);
    CHECK(ppm_path_mkdirs(evildest, 0755) == PPM_OK);
    CHECK(build_tarball(evilpath, "/etc/passwd", "pwn") == 0);
    CHECK(ppm_archive_extract(evilpath, evildest) == PPM_ERR_INVALID);

    /* --- malicious entry: parent escape --------------------------------- */
    char *uppath, *upoutpath;
    CHECK(ppm_path_join(root, "up.tar.gz", &uppath) == PPM_OK);
    CHECK(ppm_path_join(root, "upout",     &upoutpath) == PPM_OK);
    CHECK(ppm_path_mkdirs(upoutpath, 0755) == PPM_OK);
    CHECK(build_tarball(uppath, "../escape.txt", "nope") == 0);
    CHECK(ppm_archive_extract(uppath, upoutpath) == PPM_ERR_INVALID);

    /* --- symlink escape: a write-through a symlink must not escape ------ */
    /* These are NULL-initialised so the shared cleanup label can free them
     * unconditionally (free(NULL) is a no-op), including on the zstd SKIP. */
    int ret = 0;
    char *escape_target = NULL, *slpath = NULL, *sldest = NULL, *escaped_file = NULL;
    char *zpath = NULL, *zdest = NULL, *zout = NULL;

    /* The "outside" location the malicious symlink points at. If the
     * extractor is vulnerable, the payload lands at escape_target/pwned.txt. */
    CHECK(ppm_path_join(root, "escape_target", &escape_target) == PPM_OK);
    CHECK(ppm_path_mkdirs(escape_target, 0755) == PPM_OK);

    CHECK(ppm_path_join(root, "symlink.tar.gz", &slpath) == PPM_OK);
    CHECK(ppm_path_join(root, "slout", &sldest) == PPM_OK);
    CHECK(ppm_path_mkdirs(sldest, 0755) == PPM_OK);
    CHECK(build_symlink_escape_tarball(slpath, "evil", escape_target,
                                       "evil/pwned.txt", "pwned") == 0);

    /* We deliberately do NOT assert a particular return code: archive.c
     * only ppm_warn()s on a failed write_header and may still return
     * PPM_OK. The security property is what matters — nothing escaped. */
    (void)ppm_archive_extract(slpath, sldest);

    CHECK(ppm_path_join(escape_target, "pwned.txt", &escaped_file) == PPM_OK);
    CHECK(!path_exists(escaped_file));

    /* --- tar.zst round-trip (SKIP if libarchive cannot write zstd) ----- */
    if (!zstd_write_supported()) {
        fprintf(stderr, "SKIP: libarchive lacks zstd write support\n");
        ret = 77; /* automake "skipped" exit code */
        goto cleanup;
    }
    CHECK(ppm_path_join(root, "good.tar.zst", &zpath) == PPM_OK);
    CHECK(build_zstd_tarball(zpath, "payload/z.txt", "zstd-bytes") == 0);
    CHECK(ppm_path_join(root, "zout", &zdest) == PPM_OK);
    CHECK(ppm_path_mkdirs(zdest, 0755) == PPM_OK);
    CHECK(ppm_archive_extract(zpath, zdest) == PPM_OK);
    CHECK(ppm_path_join(zdest, "payload/z.txt", &zout) == PPM_OK);
    {
        FILE *zf = fopen(zout, "r");
        CHECK(zf != NULL);
        char zbuf[32] = {0};
        size_t zn = fread(zbuf, 1, sizeof(zbuf) - 1, zf);
        fclose(zf);
        CHECK(zn == 10 && strcmp(zbuf, "zstd-bytes") == 0);
    }

    printf("ok\n");

cleanup:
    ppm_path_rmrf(root);
    free(tarpath); free(destdir); free(extracted);
    free(evilpath); free(evildest);
    free(uppath); free(upoutpath);
    free(escape_target); free(slpath); free(sldest); free(escaped_file);
    free(zpath); free(zdest); free(zout);
    return ret;
}
