#!/usr/bin/env php
<?php
/**
 * =====================================================================
 *  bin/rx-release — build the update package
 *
 *  Usage:
 *    rx-release keygen <dir>              make a signing keypair
 *    rx-release build <version> <outdir>  build and sign a package
 *    rx-release verify <package.zip>      check one the way a store does
 *
 *  =================================================================
 *  THE UPDATER COULD VERIFY A PACKAGE AND NOTHING COULD BUILD ONE
 *  =================================================================
 *
 *  Updater::verifyPackage() has existed since the self-update work: it
 *  checks a signature over the manifest, refuses an unsigned package,
 *  and refuses one whose bytes do not match the hash the manifest
 *  names. All correct, and unreachable — no tool produced the thing it
 *  verifies, so the update path could never be fed.
 *
 *  Same shape as everything else found this week: the reader existed,
 *  the writer did not.
 *
 *  =================================================================
 *  WHY THE SIGNATURE COVERS THE MANIFEST
 *  =================================================================
 *
 *  Signing the package hash alone would let somebody reuse a valid
 *  signature with a different version number and walk a shop backwards
 *  onto a build with a known hole. The signature covers version,
 *  hash, minimum-upgradable-from and the schema pack list together.
 *
 *  The PRIVATE KEY IS NEVER READ FROM THE WORKING TREE. It is passed
 *  by path, and this refuses to run if it finds one committed.
 * =====================================================================
 */
declare(strict_types=1);

$root = dirname(__DIR__);
require $root . '/lib/bootstrap.php';
require $root . '/lib/fileinstaller.php';
require $root . '/lib/updater.php';

$cmd = $argv[1] ?? '';

/** Files that ship. Everything else is a working artifact. */
const SHIP = ['lib', 'public', 'schema', 'bin', 'CounterHelper'];

/** Never packaged, whatever else happens. */
const NEVER = ['config.php', 'node_modules', 'tests', '.git', 'vendor'];

function die_(string $m): never { fwrite(STDERR, "\n  $m\n\n"); exit(2); }

try {
    switch ($cmd) {

    /* ---------------------------------------------------------------- */
    case 'keygen': {
        $dir = $argv[2] ?? '';
        if ($dir === '') { die_('usage: rx-release keygen <dir>'); }
        if (!is_dir($dir)) { mkdir($dir, 0700, true); }

        $key = openssl_pkey_new([
            'private_key_bits' => 3072,
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
        ]);
        if ($key === false) { die_('could not generate a key: ' . openssl_error_string()); }

        openssl_pkey_export($key, $priv);
        $pub = openssl_pkey_get_details($key)['key'];

        $pk = rtrim($dir, '/') . '/release-private.pem';
        $pb = rtrim($dir, '/') . '/release-public.pem';
        file_put_contents($pk, $priv);
        chmod($pk, 0600);
        file_put_contents($pb, $pub);

        printf("  private key  %s   (0600 — never commit this)\n", $pk);
        printf("  public key   %s   (ships in config.php)\n\n", $pb);
        echo "  Put the private key on ONE machine, offline if you can. A store\n"
           . "  accepts any package this key signs, so the key IS the update\n"
           . "  channel. Losing it means every shop stops updating; leaking it\n"
           . "  means somebody else can update every shop.\n";
        break;
    }

    /* ---------------------------------------------------------------- */
    case 'build': {
        $version = $argv[2] ?? '';
        $outdir  = $argv[3] ?? '';
        $keyPath = $argv[4] ?? getenv('RX_SIGNING_KEY') ?: '';

        if ($version === '' || $outdir === '') {
            die_('usage: rx-release build <version> <outdir> [private-key.pem]');
        }
        if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
            die_("version must look like 1.2.3, got: {$version}");
        }
        if ($keyPath === '' || !is_file($keyPath)) {
            die_('no signing key. Pass one, or set RX_SIGNING_KEY.');
        }

        // A key inside the tree is a key that will be committed.
        $realKey = realpath($keyPath);
        if ($realKey !== false && str_starts_with($realKey, (string) realpath($root))) {
            die_('the signing key is inside the working tree. Move it out: '
               . 'a key that lives next to the code gets committed with it.');
        }

        $priv = openssl_pkey_get_private((string) file_get_contents($keyPath));
        if ($priv === false) { die_('that key could not be read: ' . openssl_error_string()); }

        if (!is_dir($outdir)) { mkdir($outdir, 0755, true); }
        $zipPath = rtrim($outdir, '/') . "/rx-{$version}.zip";
        if (is_file($zipPath)) { unlink($zipPath); }

        $zip = new ZipArchive();
        if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
            die_("could not create {$zipPath}");
        }

        $count = 0; $files = [];
        foreach (SHIP as $top) {
            $dir = $root . '/' . $top;
            if (!is_dir($dir)) { continue; }
            $it = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
            foreach ($it as $f) {
                if (!$f->isFile()) { continue; }
                $rel = str_replace($root . '/', '', $f->getPathname());
                foreach (NEVER as $bad) {
                    if (str_starts_with($rel, $bad . '/') || $rel === $bad) { continue 2; }
                }
                // A stray key or dump in the tree must not ship.
                if (preg_match('/\.(pem|key|sql\.gz|log|bak)$/i', $rel)
                    && !str_starts_with($rel, 'schema/')) { continue; }
                $zip->addFile($f->getPathname(), $rel);
                $files[$rel] = hash_file('sha256', $f->getPathname());
                $count++;
            }
        }
        $zip->close();
        ksort($files);

        $bytes = (string) file_get_contents($zipPath);
        $sha   = hash('sha256', $bytes);

        // The pack list lets a store refuse a package that would skip a
        // migration it has not run.
        $packs = array_values(array_map('basename', glob($root . '/schema/*.sql') ?: []));
        sort($packs);

        $manifest = [
            'version'  => $version,
            'sha256'   => $sha,
            'min_from' => $argv[5] ?? '0.0.0',
            'packs'    => $packs,
            'built_at' => gmdate('c'),
            'files'    => count($files),
        ];

        // EXACTLY the payload verifyPackage() rebuilds — key order and
        // flags included. A mismatch here fails at every store at once.
        $payload = json_encode([
            'version'  => $manifest['version'],
            'sha256'   => $manifest['sha256'],
            'min_from' => $manifest['min_from'],
            'packs'    => $manifest['packs'],
        ], JSON_UNESCAPED_SLASHES);

        $sig = '';
        if (!openssl_sign($payload, $sig, $priv, OPENSSL_ALGO_SHA256)) {
            die_('signing failed: ' . openssl_error_string());
        }
        $manifest['signature'] = base64_encode($sig);

        $manPath = rtrim($outdir, '/') . "/rx-{$version}.manifest.json";
        file_put_contents($manPath, json_encode($manifest, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));

        printf("\n  %s\n", basename($zipPath));
        printf("    %d file(s), %s\n", $count, number_format(strlen($bytes) / 1024, 0) . ' KB');
        printf("    sha256  %s\n", $sha);
        printf("    packs   %d\n", count($packs));
        printf("  %s\n\n", basename($manPath));

        // Verify our own output the way a store will. A release that
        // fails its own check must never leave this machine.
        // The SAME check a store runs — the static, not a copy of it.
        $pubPem = openssl_pkey_get_details($priv)['key'];
        $r = Updater::checkSignature($bytes, $manifest['signature'], $manifest, $pubPem);
        if (($r['ok'] ?? false) !== true) {
            unlink($zipPath); unlink($manPath);
            die_('THE PACKAGE FAILED ITS OWN VERIFICATION: ' . ($r['reason'] ?? '?')
               . "\n  Nothing was written. This is a bug in the builder, not the key.");
        }
        echo "  Verified against the same check a store runs.\n";
        break;
    }

    /* ---------------------------------------------------------------- */
    case 'verify': {
        $zipPath = $argv[2] ?? '';
        if ($zipPath === '' || !is_file($zipPath)) {
            die_('usage: rx-release verify <package.zip>');
        }
        $manPath = preg_replace('/\.zip$/', '.manifest.json', $zipPath);
        if (!is_file((string) $manPath)) { die_("no manifest beside {$zipPath}"); }

        $manifest = json_decode((string) file_get_contents((string) $manPath), true) ?: [];
        $cfg = require $root . '/config.php';
        $pub = (string) ($cfg['update_pubkey'] ?? '');
        if ($pub === '') { die_('config.php has no update_pubkey — a store could not verify this either'); }

        $r = Updater::checkSignature((string) file_get_contents($zipPath),
                                     (string) ($manifest['signature'] ?? ''), $manifest, $pub);
        printf("\n  %s\n\n", ($r['ok'] ?? false) ? 'VALID — a store would accept this.'
                                                 : 'REJECTED: ' . ($r['reason'] ?? '?'));
        exit(($r['ok'] ?? false) ? 0 : 1);
    }

    default:
        fwrite(STDERR, "usage: rx-release [keygen|build|verify] ...\n");
        exit(1);
    }
} catch (Throwable $e) {
    die_($e->getMessage());
}
