From f658d6df764132d1251b0d4cde57ad762d55d57e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:43:17 +0100 Subject: [PATCH] fix(logs): show container logs that contain invalid UTF-8 Closes #10628 --- app/Livewire/Project/Shared/GetLogs.php | 5 +- tests/Feature/GetLogsInvalidUtf8Test.php | 76 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/GetLogsInvalidUtf8Test.php diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index cabf0bc469..3ede8bc45a 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -297,7 +297,7 @@ class GetLogs extends Component $stripTimestamps ??= $export && ! $this->showTimeStamps; $merged = $this->joinMergedLines($groups, $stripTimestamps, $export); if (strlen($merged) > $maxBytes) { - $merged = rtrim(substr($merged, 0, $maxBytes), "\n"); + $merged = rtrim(mb_strcut($merged, 0, $maxBytes, 'UTF-8'), "\n"); $truncated = true; } if ($truncated && $merged !== '') { @@ -501,7 +501,8 @@ class GetLogs extends Component // boundedLogCommand() caps the output at $maxBytes + 1, so one extra byte means truncation. $output = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand)->output(); $truncated = strlen($output) > $maxBytes; - $logs = rtrim(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output), "\n"); + // Invalid UTF-8 from a container would make the JSON response fail, so the viewer would stay empty. + $logs = rtrim(sanitize_utf8_text(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output)), "\n"); return ['logs' => $logs, 'truncated' => $truncated]; } diff --git a/tests/Feature/GetLogsInvalidUtf8Test.php b/tests/Feature/GetLogsInvalidUtf8Test.php new file mode 100644 index 0000000000..742c5495f5 --- /dev/null +++ b/tests/Feature/GetLogsInvalidUtf8Test.php @@ -0,0 +1,76 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $server->settings->fill(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false])->save(); + $this->server = Server::with('settings')->findOrFail($server->id); + + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockercompose', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('returns container logs that contain invalid UTF-8 bytes', function () { + Process::fake(['*' => Process::result(output: "2026-06-17T21:00:00Z job \xC3\x28 processed\n2026-06-17T21:00:01Z done\n")]); + + $logs = Livewire::test(GetLogs::class, [ + 'server' => $this->server, + 'resource' => $this->application, + 'container' => 'worker-abc123', + ])->call('getLogs')->effects['returns'][0]; + + expect(mb_check_encoding($logs, 'UTF-8'))->toBeTrue() + ->and($logs)->toContain('2026-06-17T21:00:00Z job ') + ->and($logs)->toContain('processed') + ->and($logs)->toContain('2026-06-17T21:00:01Z done'); +}); + +it('keeps merged logs valid UTF-8 when the size limit cuts them', function (int $padding) { + $line = '2026-06-17T21:00:00Z '.str_repeat('a', $padding).str_repeat('€', 1000); + Process::fake(['*' => Process::result(output: implode("\n", array_fill(0, 1000, $line)))]); + + $logs = Livewire::test(GetLogs::class, [ + 'server' => $this->server, + 'resource' => $this->application, + 'container' => 'worker-1', + 'sources' => [ + ['server' => $this->server->uuid, 'container' => 'worker-1', 'label' => 'worker-1'], + ['server' => $this->server->uuid, 'container' => 'worker-2', 'label' => 'worker-2'], + ], + ])->call('getLogs')->effects['returns'][0]; + + expect(mb_check_encoding($logs, 'UTF-8'))->toBeTrue() + ->and($logs)->toEndWith('[... Output truncated at 5MB limit ...]'); +})->with([0, 1, 2]);