<?php
// scripts/test-cpanel-import-symlink-rejection.php
//
// Regression test for the pre-extraction symlink scan added to
// CpanelBackupImporter (see audit: /tmp/cpanel-import-audit.md, 2026-05-29).
// Builds a synthetic cpmove tarball containing an ALFA TEaM Shell style
// `homedir/.../alfasymlink/root -> /` symlink and asserts that the scan
// classifies it as DANGEROUS so the import would fail-safe before any
// extraction occurs.
//
// Run with: php scripts/test-cpanel-import-symlink-rejection.php
// Exits 0 on success, 1 on failure.
//
// Cleanup is bounded: unlink files we created and rmdir the specific
// dirs we made. No recursive shell deletes (the destructive-delete
// hookify rule blocks them, and the test must run unattended).

require_once __DIR__ . '/../web-files/libs/CpanelBackupImporter.php';

$failures = 0;
$createdFiles = [];
$createdDirs = [];

function assertTrue(bool $cond, string $label): void {
    global $failures;
    if (!$cond) { echo "  FAIL  $label\n"; $failures++; }
    else        { echo "  PASS  $label\n"; }
}

function assertEquals($expected, $actual, string $label): void {
    global $failures;
    if ($expected !== $actual) {
        echo "  FAIL  $label\n    expected: " . var_export($expected, true)
            . "\n    actual:   " . var_export($actual, true) . "\n";
        $failures++;
    } else {
        echo "  PASS  $label\n";
    }
}

// Bounded-cleanup helpers — we only ever touch paths we created during
// this test run.
function mkdir_tracked(string $dir): void {
    global $createdDirs;
    if (!is_dir($dir)) {
        if (!mkdir($dir, 0755, true)) {
            fwrite(STDERR, "mkdir failed for $dir\n");
            exit(1);
        }
    }
    $createdDirs[] = $dir;
}
function write_tracked(string $path, string $contents): void {
    global $createdFiles;
    file_put_contents($path, $contents);
    $createdFiles[] = $path;
}
function symlink_tracked(string $target, string $link): void {
    global $createdFiles;
    if (!@symlink($target, $link)) {
        fwrite(STDERR, "symlink($target -> $link) failed\n");
        exit(1);
    }
    $createdFiles[] = $link;
}

// ---------------------------------------------------------------------
// Build a synthetic cpmove tarball that mimics the structure of a real
// cPanel export from a compromised WordPress site.
//
//   cpmove-testuser/
//     userdata/test.example.com           <- minimum userdata file
//     homedir/public_html/index.php       <- a real file
//     homedir/public_html/alfa_root -> /  <- THE BOOBY TRAP
//
// Plus a few benign symlinks so we also exercise the suppression paths:
//   homedir/.cpanel/ea-php-cli/test.example.com/.ea-php-cli.cache
//     -> ea-php80                          <- relative, ignored by scan
//   homedir/public_html/db.php
//     -> /home/testuser/legit/db.php       <- BENIGN_USER_INTERNAL
//   homedir/access-logs
//     -> /usr/local/apache/domlogs/xxxx    <- BENIGN_DANGLING (target absent)
// ---------------------------------------------------------------------

$workDir = sys_get_temp_dir() . '/cpmove-symlink-test-' . bin2hex(random_bytes(3));
$buildDir = $workDir . '/build';
$cpmoveRoot = $buildDir . '/cpmove-testuser';
$tarballPath = $workDir . '/cpmove-testuser.tar.gz';

mkdir_tracked($workDir);
mkdir_tracked($buildDir);
mkdir_tracked($cpmoveRoot);
mkdir_tracked($cpmoveRoot . '/userdata');
mkdir_tracked($cpmoveRoot . '/homedir');
mkdir_tracked($cpmoveRoot . '/homedir/public_html');
mkdir_tracked($cpmoveRoot . '/homedir/.cpanel');
mkdir_tracked($cpmoveRoot . '/homedir/.cpanel/ea-php-cli');
mkdir_tracked($cpmoveRoot . '/homedir/.cpanel/ea-php-cli/test.example.com');

write_tracked($cpmoveRoot . '/userdata/test.example.com',
    "documentroot: /home/testuser/public_html\nuser: testuser\n");
write_tracked($cpmoveRoot . '/homedir/public_html/index.php',
    "<?php phpinfo();\n");

// THE BOOBY TRAP — ALFA-style absolute-root symlink.
symlink_tracked('/', $cpmoveRoot . '/homedir/public_html/alfa_root');

// Benign symlinks to exercise the suppression branches.
symlink_tracked('ea-php80',
    $cpmoveRoot . '/homedir/.cpanel/ea-php-cli/test.example.com/.ea-php-cli.cache');
symlink_tracked('/home/testuser/legit/db.php',
    $cpmoveRoot . '/homedir/public_html/db.php');
// BENIGN_DANGLING: target almost certainly absent on the test host.
symlink_tracked('/usr/local/apache/domlogs/testuser-nope',
    $cpmoveRoot . '/homedir/access-logs');

echo "Building synthetic cpmove tarball at $tarballPath\n";
$tarCmd = 'tar -czf ' . escapeshellarg($tarballPath)
        . ' -C ' . escapeshellarg($buildDir) . ' cpmove-testuser';
$tarOut = [];
$tarRc = 0;
exec($tarCmd . ' 2>&1', $tarOut, $tarRc);
if ($tarRc !== 0) {
    fwrite(STDERR, "tar failed: " . implode("\n", $tarOut) . "\n");
    exit(1);
}
$createdFiles[] = $tarballPath;
assertTrue(is_file($tarballPath), "tarball exists at $tarballPath");

// ---------------------------------------------------------------------
// Invoke the scan via reflection.
// ---------------------------------------------------------------------
echo "\n== Scan ==\n";
$rc = new ReflectionClass('CpanelBackupImporter');
$importer = $rc->newInstanceWithoutConstructor();
$up = $rc->getProperty('username'); $up->setAccessible(true);
$up->setValue($importer, 'testuser');
$m = $rc->getMethod('scanTarballForDangerousSymlinks'); $m->setAccessible(true);
$findings = $m->invoke($importer, $tarballPath);

echo "  scan returned " . count($findings) . " finding(s):\n";
foreach ($findings as $f) {
    echo "    [" . $f['type'] . "] " . $f['archive_path']
       . " -> " . $f['target'] . "  (" . $f['reason'] . ")\n";
}

// Core assertion: scan flagged at least one DANGEROUS entry with target /.
$dangerous = array_values(array_filter($findings, fn($f) => $f['type'] === 'DANGEROUS'));
assertTrue(count($dangerous) >= 1, 'scan returns at least one DANGEROUS entry');
$rootHits = array_values(array_filter($dangerous, fn($f) => $f['target'] === '/'));
assertEquals(1, count($rootHits), 'scan flags exactly one DANGEROUS entry with target = /');
if (!empty($rootHits)) {
    assertTrue(
        str_contains($rootHits[0]['archive_path'], 'alfa_root'),
        'DANGEROUS root-target finding points at the alfa_root symlink we planted'
    );
}

// Suppression checks: BENIGN_* cases must NOT appear in the output.
$benignLeaked = array_values(array_filter($findings, fn($f) =>
    str_ends_with($f['archive_path'], '/db.php')
    || str_ends_with($f['archive_path'], '/access-logs')
    || str_ends_with($f['archive_path'], '/.ea-php-cli.cache')
));
assertEquals(0, count($benignLeaked), 'benign symlinks are suppressed from scan output');

// ---------------------------------------------------------------------
// Source-level check that importBackup() actually wires the scan into
// the abort path. importBackup() needs PDO + DB to actually run, which
// we don't have locally, so we verify the call-site contract by reading
// the source: invocation present, exception thrown, classification by
// DANGEROUS. Cheap but catches a future refactor that drops the throw.
// ---------------------------------------------------------------------
echo "\n== importBackup wiring (source check) ==\n";

$src = file_get_contents(__DIR__ . '/../web-files/libs/CpanelBackupImporter.php');
assertTrue(
    strpos($src, 'scanTarballForDangerousSymlinks($this->backupPath)') !== false,
    'importBackup() invokes scanTarballForDangerousSymlinks against backupPath'
);
assertTrue(
    strpos($src, 'cPanel backup contains symlinks pointing at real host paths') !== false,
    'importBackup() throws with the "real host paths" message on DANGEROUS findings'
);
assertTrue(
    strpos($src, "'DANGEROUS'") !== false,
    'importBackup() classifies findings by DANGEROUS before deciding to abort'
);

// ---------------------------------------------------------------------
// Bounded cleanup. We delete exactly what we created, nothing else.
// ---------------------------------------------------------------------
echo "\n== Cleanup ==\n";
foreach (array_reverse($createdFiles) as $p) {
    if (is_link($p) || is_file($p)) {
        @unlink($p);
    }
}
foreach (array_reverse($createdDirs) as $d) {
    if (is_dir($d)) {
        @rmdir($d);
    }
}
echo "  removed " . count($createdFiles) . " file(s), "
   . count($createdDirs) . " directory(ies)\n";

if ($failures > 0) {
    echo "\n$failures failure(s)\n";
    exit(1);
}
echo "\nAll tests passed.\n";
exit(0);
