Ctrl K
ComunidadGuías…

tests/Feature/ContainerFilesystemServiceTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

use App\Data\FileEntry;
use App\Exceptions\ContainerShellUnavailableException;
use App\Models\LocalFileVolume;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Services\ContainerFilesystemService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;

uses(RefreshDatabase::class);

beforeEach(function () {
    Storage::fake('ssh-keys');
    // Disable SSH multiplexing so each instant_remote_process is exactly one
    // faked Process call (mux setup would run extra calls and desync sequences).
    config(['constants.ssh.mux_enabled' => false]);
});

function fsService(): ContainerFilesystemService
{
    $server = Server::factory()->make(['id' => 999]);

    return new ContainerFilesystemService($server, 'app-123');
}

function fsServer(): Server
{
    $team = Team::factory()->create();
    $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);

    $server = Server::factory()->create([
        'team_id' => $team->id,
        'private_key_id' => $privateKey->id,
        'ip' => '203.0.113.10',
    ]);
    // These tests read the faked ssh commands in order; the host key check would add a key scan.
    $server->settings->forceFill(['is_ssh_host_key_check_disabled' => true])->save();

    return $server;
}

it('sorts directories before files, then by name case-insensitively', function () {
    $entries = [
        new FileEntry('README.md', 'file', 10, 100),
        new FileEntry('src', 'dir', 0, 100),
        new FileEntry('.env', 'file', 5, 100),
        new FileEntry('assets', 'dir', 0, 100),
    ];

    $names = array_map(fn (FileEntry $e) => $e->name, FileEntry::sort($entries));

    expect($names)->toBe(['assets', 'src', '.env', 'README.md']);
});

it('builds a listing command with a single stat call, escaped path and container name', function () {
    $cmd = fsService()->buildListCommand('/var/www/html');

    expect($cmd)
        ->toContain('docker exec')
        ->toContain('app-123')
        ->toContain('stat -c')
        ->toContain(escapeshellarg('/var/www/html'))
        // Must embed a REAL tab, not a literal backslash-t: stat -c does not
        // expand \t, so a literal escape would break the tab-delimited parse.
        ->toContain("%F\t%s")
        ->not->toContain('%F\\t%s')
        // find batches names, so a huge folder cannot overflow the argument limit like a glob.
        ->toContain('find . ! -name . -prune -exec stat -c')
        ->not->toContain('* .*');
});

it('rejects an unsafe listing path', function () {
    fsService()->buildListCommand('/tmp/$(reboot)');
})->throws(Exception::class);

it('parses a tab-delimited listing into sorted FileEntry rows', function () {
    $raw = implode("\n", [
        "file\t10\t1700000000\tREADME.md",
        "dir\t0\t1700000001\tsrc",
        "file\t5\t1700000002\t.env",
    ]);

    $entries = fsService()->parseListing($raw);

    expect($entries)->toHaveCount(3);
    expect($entries[0]->name)->toBe('src');
    expect($entries[0]->type)->toBe('dir');
    expect($entries[1]->name)->toBe('.env');
    expect($entries[2]->name)->toBe('README.md');
});

it('strips the ./ prefix that find adds to listed names', function () {
    $raw = implode("\n", [
        "directory\t0\t1\t755\troot\troot\t./src",
        "regular file\t5\t2\t644\troot\troot\t./.env",
    ]);

    $names = array_map(fn ($entry) => $entry->name, fsService()->parseListing($raw));

    expect($names)->toBe(['src', '.env']);
});

it('parses an empty listing to an empty array', function () {
    expect(fsService()->parseListing(null))->toBe([]);
    expect(fsService()->parseListing(''))->toBe([]);
});

it('lists a directory by running the built command over SSH', function () {
    Process::fake(['*' => Process::result(output: "dir\t0\t1\tsrc\nfile\t10\t2\tREADME.md")]);

    $server = fsServer();
    $entries = (new ContainerFilesystemService($server, 'app-123'))->list('/app');

    expect($entries)->toHaveCount(2);
    expect($entries[0]->name)->toBe('src');
});

it('reports a container without a shell instead of an empty folder', function (int $exitCode) {
    Process::fake(['*' => Process::result(
        errorOutput: 'OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH',
        exitCode: $exitCode,
    )]);

    (new ContainerFilesystemService(fsServer(), 'app-123'))->list('/');
})->with([126, 127])->throws(ContainerShellUnavailableException::class);

it('lists other docker exec failures as an empty folder', function () {
    Process::fake(['*' => Process::result(errorOutput: 'Error response from daemon: container is not running', exitCode: 1)]);

    expect((new ContainerFilesystemService(fsServer(), 'app-123'))->list('/'))->toBe([]);
});

function fsProcessStatus(string $uid, string $gid, string $groups, string $capPrm): string
{
    return "Name:\tapp\nUid:\t{$uid}\t{$uid}\t{$uid}\t{$uid}\nGid:\t{$gid}\t{$gid}\t{$gid}\t{$gid}\nGroups:\t{$groups} \nCapInh:\t0000000000000000\nCapPrm:\t{$capPrm}\nCapEff:\t{$capPrm}\n";
}

it('reads the user, groups and capabilities of the container process for the helper', function () {
    // Docker's default capability set for a root process.
    expect(fsService()->parseProcessStatus(fsProcessStatus('0', '0', '0 1 2', '00000000a80425fb')))->toBe([
        'user' => '0:0',
        'groups' => ['1', '2'],
        'capabilities' => ['CHOWN', 'DAC_OVERRIDE', 'FOWNER', 'FSETID', 'KILL', 'SETGID', 'SETUID', 'SETPCAP', 'NET_BIND_SERVICE', 'NET_RAW', 'SYS_CHROOT', 'MKNOD', 'AUDIT_WRITE', 'SETFCAP'],
    ]);
    expect(fsService()->parseProcessStatus(fsProcessStatus('65532', '0', '0', '0000000000000000')))
        ->toBe(['user' => '65532:0', 'groups' => [], 'capabilities' => []]);
    expect(fsService()->parseProcessStatus(fsProcessStatus('0', '0', '', '000001ffffffffff'))['capabilities'])
        ->toHaveCount(41);
});

it('rejects an unreadable process status', function (string $status) {
    fsService()->parseProcessStatus($status);
})->with(['', fsProcessStatus('x', '0', '', '0'), fsProcessStatus('0', '0', '', 'zz')])->throws(RuntimeException::class);

it('lists through a locked-down helper container that runs as the container process', function () {
    $server = fsServer();
    $server->update(['user' => 'root']);
    $commands = [];
    Process::fake(function ($process) use (&$commands) {
        $commands[] = remoteProcessScript($process);

        return str_contains(remoteProcessScript($process), '/status')
            ? Process::result(output: fsProcessStatus('65532', '0', '0 1000', '0000000000000000'))
            : Process::result(output: "regular file\t9\t1\t644\troot\troot\t./users.yml");
    });

    $entries = (new ContainerFilesystemService($server, 'app-123', useHelper: true))->list('/data');

    expect($entries)->toHaveCount(1)
        ->and($entries[0]->name)->toBe('users.yml')
        ->and($commands[0])->toContain("cat /proc/$(docker inspect --format '{{.State.Pid}}' 'app-123')/status")
        ->and($commands[1])->toContain("docker run --rm --pid container:'app-123' --user '65532:0' --group-add '1000' --cap-drop ALL --security-opt no-new-privileges --network none --ipc none --read-only --log-driver none --pids-limit 64")
        ->not->toContain('--cap-add')
        ->not->toContain('docker exec')
        ->toContain('/proc/1/root/data')
        ->toContain('exit 126');
});

it('reports every helper failure instead of an empty folder', function () {
    Process::fake(['*' => Process::sequence()
        ->push(Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb')))
        ->push(Process::result(errorOutput: 'Unable to find image', exitCode: 125))]);

    (new ContainerFilesystemService(fsServer(), 'app-123', useHelper: true))->list('/');
})->throws(ContainerShellUnavailableException::class, 'Unable to find image');

it('wraps the helper command in one sudo shell for non-root servers', function () {
    $server = fsServer();
    $server->update(['user' => 'deploy']);
    $commands = [];
    Process::fake(function ($process) use (&$commands) {
        $commands[] = remoteProcessScript($process);

        return count($commands) === 1
            ? Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb'))
            : Process::result(output: '');
    });

    (new ContainerFilesystemService($server, 'app-123', useHelper: true))->list('/');

    expect($commands[0])->toContain("sudo cat /proc/$(sudo docker inspect --format '{{.State.Pid}}' 'app-123')/status")
        ->and($commands[1])->toContain("sudo bash -c 'docker run --rm")
        ->and(substr_count($commands[1], 'sudo'))->toBe(1);
});

it('streams downloads through the helper container in helper mode', function () {
    Process::fake(['*' => Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb'))]);
    $service = new ContainerFilesystemService(fsServer(), 'app-123', useHelper: true);

    expect($service->buildDownloadCommand('/data', true))->toStartWith('docker run --rm')->toContain("tar czf - -C '\\''/proc/1/root/data'\\'' .")
        ->and($service->buildDownloadCommand('/data/users.yml', false))->toContain("cat -- '\\''/proc/1/root/data/users.yml'\\''");
});

it('rejects a binary file that passes a grep without -I support', function () {
    Process::fake(['*' => Process::result(output: "OK\n".base64_encode("ELF\0binary"))]);

    (new ContainerFilesystemService(fsServer(), 'app-123'))->read('/pause');
})->throws(RuntimeException::class, 'File is not editable');

it('falls back to / when the container has no WorkingDir', function () {
    Process::fake(['*' => Process::result(output: '')]);

    $server = fsServer();
    expect((new ContainerFilesystemService($server, 'app-123'))->defaultRoot())->toBe('/');
});

it('refuses to read a file larger than the edit cap', function () {
    // The combined read command emits TOOBIG in a single round trip.
    Process::fake(['*' => Process::result(output: 'TOOBIG')]);

    $server = fsServer();
    (new ContainerFilesystemService($server, 'app-123'))->read('/app/big.bin');
})->throws(RuntimeException::class);

it('refuses to read a binary file', function () {
    Process::fake(['*' => Process::result(output: 'BINARY')]);

    $server = fsServer();
    (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.bin');
})->throws(RuntimeException::class);

it('reads an editable text file in a single round trip', function () {
    // "OK" header line then the base64 payload, from one docker exec.
    Process::fake(['*' => Process::result(output: "OK\n".base64_encode("hello world\n"))]);

    $server = fsServer();
    $content = (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.txt');

    expect($content)->toBe("hello world\n");
});

it('treats an empty file as editable text', function () {
    // Empty file => "OK" header with no payload.
    Process::fake(['*' => Process::result(output: 'OK')]);

    $server = fsServer();

    expect((new ContainerFilesystemService($server, 'app-123'))->read('/app/empty.txt'))->toBe('');
});

it('writes content near or above the shell argument limit via docker cp', function (int $size) {
    Process::fake(['*' => Process::result(output: '')]);
    $server = fsServer();

    (new ContainerFilesystemService($server, 'app-123'))->write('/app/big.txt', str_repeat('a', $size));

    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker cp'));
})->with([96 * 1024, 200_000]);

it('base64-encodes content in the write command', function () {
    $cmd = fsService()->buildWriteCommand('/app/a.txt', "hi\n");

    expect($cmd)
        ->toContain('base64 -d')
        ->toContain(base64_encode("hi\n"))
        ->toContain(escapeshellarg('/app/a.txt'));
});

it('builds mkdir, rename and delete commands with escaped paths', function () {
    $svc = fsService();

    expect($svc->buildMkdirCommand('/app/new dir'))
        ->toContain('mkdir -p')
        ->toContain(escapeshellarg('/app/new dir'));
    expect($svc->buildRenameCommand('/app/a', '/app/b'))
        ->toContain('mv')
        ->toContain(escapeshellarg('/app/a'))
        ->toContain(escapeshellarg('/app/b'));
    expect($svc->buildDeleteCommand('/app/x'))
        ->toContain('rm -rf')
        ->toContain(escapeshellarg('/app/x'));
});

it('rejects unsafe paths in mutating builders', function () {
    fsService()->buildDeleteCommand('/app/$(reboot)');
})->throws(Exception::class);

it('parses the 7-field stat listing with owner and group, mapping the file type', function () {
    $raw = implode("\n", [
        "directory\t4096\t1700000000\t755\troot\troot\t.",
        "directory\t4096\t1700000000\t755\troot\troot\t..",
        "regular file\t497\t1700000001\t644\twww-data\twww-data\t50x.html",
        "directory\t4096\t1700000002\t755\tapp\tapp\tassets",
        "symbolic link\t11\t1700000003\t777\troot\troot\tlink",
    ]);

    $entries = fsService()->parseListing($raw);

    // The stat . and .. rows are dropped.
    expect($entries)->toHaveCount(3);
    expect($entries[0]->name)->toBe('assets');
    expect($entries[0]->type)->toBe('dir');
    expect($entries[0]->owner)->toBe('app');
    expect($entries[0]->group)->toBe('app');
    expect($entries[1]->name)->toBe('50x.html');
    expect($entries[1]->type)->toBe('file');
    expect($entries[1]->owner)->toBe('www-data');
    expect($entries[2]->name)->toBe('link');
    expect($entries[2]->type)->toBe('symlink');
});

it('parses permissions from the 5-field listing format', function () {
    $raw = "file\t497\t1700000000\t644\t50x.html\ndir\t0\t1700000001\t755\tassets";

    $entries = fsService()->parseListing($raw);

    expect($entries[0]->name)->toBe('assets');
    expect($entries[0]->perms)->toBe('755');
    expect($entries[1]->name)->toBe('50x.html');
    expect($entries[1]->perms)->toBe('644');
});

it('builds a create-file command that does not truncate an existing file', function () {
    $cmd = fsService()->buildCreateFileCommand('/app/new file.txt');

    expect($cmd)
        ->toContain('[ -e')
        ->toContain(': >')
        ->toContain(escapeshellarg('/app/new file.txt'));
});

it('builds a chmod command with a validated octal mode', function () {
    $cmd = fsService()->buildChmodCommand('/app/x.sh', '0755');

    expect($cmd)
        ->toContain('chmod 0755 --')
        ->toContain(escapeshellarg('/app/x.sh'));
});

it('rejects a non-octal chmod mode', function () {
    fsService()->buildChmodCommand('/app/x.sh', 'rwx');
})->throws(InvalidArgumentException::class);

it('rejects an unsafe path in the chmod builder', function () {
    fsService()->buildChmodCommand('/app/$(reboot)', '644');
})->throws(Exception::class);

it('uploads by scp-ing to the server then docker cp into the container', function () {
    Process::fake(['*' => Process::result(output: '')]);
    $server = fsServer();
    $local = tempnam(sys_get_temp_dir(), 'up');
    file_put_contents($local, 'data');

    (new ContainerFilesystemService($server, 'app-123'))->upload($local, '/app/plugins/x.jar');

    Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker cp')
        && str_contains(remoteProcessScript($process), 'app-123'));
    @unlink($local);
});

it('rejects failed or corrupt read responses', function (string $output) {
    Process::fake(['*' => Process::result(output: $output)]);
    (new ContainerFilesystemService(fsServer(), 'app-123'))->read('/app/a.txt');
})->with(['', 'ERROR', "OK\n!!!"])->throws(RuntimeException::class);

it('executes read guards for real file bytes through the non-root sudo parser', function (string $content, bool $editable) {
    $server = fsServer();
    $server->update(['user' => 'deploy']);
    $path = tempnam(sys_get_temp_dir(), 'fs-read-');
    file_put_contents($path, $content);
    Process::fake(function ($process) {
        $lines = explode("\n", remoteProcessScript($process));
        $body = implode("\n", array_slice($lines, 1, -1));
        $local = new Symfony\Component\Process\Process([
            'bash', '-c',
            'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body,
        ]);
        $local->run();

        return Process::result(output: $local->getOutput(), errorOutput: $local->getErrorOutput(), exitCode: $local->getExitCode());
    });
    try {
        $service = new ContainerFilesystemService($server, 'app-123');
        if ($editable) {
            expect($service->read($path))->toBe($content);
        } else {
            expect(fn () => $service->read($path))->toThrow(RuntimeException::class);
        }
    } finally {
        @unlink($path);
    }
})->with([
    'empty' => ['', true],
    'newline only' => ["\n\n", true],
    'text' => ["hello\n", true],
    'binary' => ["hello\0world", false],
]);

it('rejects a symlink to an oversized file before reading its content', function () {
    $server = fsServer();
    $path = tempnam(sys_get_temp_dir(), 'fs-size-');
    $link = $path.'-link';
    $handle = fopen($path, 'w');
    ftruncate($handle, LocalFileVolume::MAX_CONTENT_SIZE + 1);
    fclose($handle);
    symlink($path, $link);
    Process::fake(function ($process) {
        $body = implode("\n", array_slice(explode("\n", remoteProcessScript($process)), 1, -1));
        $local = new Symfony\Component\Process\Process(['bash', '-c', 'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body]);
        $local->run();

        return Process::result(output: $local->getOutput(), exitCode: $local->getExitCode());
    });
    try {
        expect(fn () => (new ContainerFilesystemService($server, 'app-123'))->read($link))
            ->toThrow(RuntimeException::class, 'File is not editable');
    } finally {
        @unlink($link);
        @unlink($path);
    }
});

it('reads download type and size in one round trip', function (string $output, bool $isDir, int $size) {
    Process::fake(['*' => Process::result(output: $output)]);

    $info = (new ContainerFilesystemService(fsServer(), 'app-123'))->downloadInfo('/app/target');

    expect($info)->toBe(['isDir' => $isDir, 'size' => $size]);
    Process::assertRanTimes(fn () => true, 1);
})->with([
    'file' => ['file 497', false, 497],
    'folder in KiB' => ['dir 4', true, 4096],
]);

it('rejects a download target that does not exist', function () {
    Process::fake(['*' => Process::result(output: '')]);

    (new ContainerFilesystemService(fsServer(), 'app-123'))->downloadInfo('/app/missing');
})->throws(RuntimeException::class, 'File not found.');

it('builds download commands that write to stdout instead of a temp file', function () {
    $svc = fsService();

    expect($svc->buildDownloadCommand("/app/it's.txt", false))
        ->toBe("docker exec 'app-123' cat -- ".escapeshellarg("/app/it's.txt"));
    expect($svc->buildDownloadCommand('/app/dir', true))
        ->toBe("docker exec 'app-123' tar czf - -C '/app/dir' .");
});

it('streams the remote command output to the writer in chunks', function () {
    $server = fsServer();
    $svc = new class($server, 'app-123') extends ContainerFilesystemService
    {
        public string $stdinFile;

        protected function sshCommand(): string
        {
            $this->stdinFile = tempnam(sys_get_temp_dir(), 'fs-stream-');

            return 'cat > '.escapeshellarg($this->stdinFile).'; head -c 200000 /dev/zero';
        }
    };
    $received = '';
    $chunks = 0;

    $svc->streamDownload('/app/a.bin', false, function (string $chunk) use (&$received, &$chunks) {
        $received .= $chunk;
        $chunks++;
    });

    $remoteCommand = file_get_contents($svc->stdinFile);
    unlink($svc->stdinFile);
    expect($remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'\n");
    expect(strlen($received))->toBe(200000);
    expect($chunks)->toBeGreaterThan(1);
});