Transmita arquivos tar em PHP com buffering limitado
Use proc_open() para ler a saída do GNU tar um bloco por vez e enviá-la como resposta HTTP.
O PHP não precisa manter o arquivo tar na memória nem gravar um arquivo tar temporário. Há uma
contrapartida: se tar falhar depois que a resposta começar, o cliente pode receber HTTP 200 e um
arquivo tar legível ao qual faltam arquivos. Um download bem-sucedido não prova que o arquivo tar
está completo.
Este passo a passo cria um endpoint local de download para um diretório fixo de logs de build
concluídos e depois verifica cada membro esperado e seus bytes. Ele foi testado no Linux com o
servidor local do PHP 8.5.10 e com PHP-FPM 8.4.26 atrás do Nginx 1.28.3, usando GNU tar 1.35. Você
também precisa de Bash, cURL e cmp, com proc_open() habilitado no PHP. Windows e BSD tar estão fora do
escopo deste passo a passo.
Mantenha o arquivo tar fora dos buffers do PHP
O memory_limit do PHP continua valendo. O benefício aqui é que o loop de transferência retém apenas
um bloco de 8 KiB, em vez de uma string do tamanho do arquivo tar. O processo tar separado, o
runtime do PHP, o servidor web e o sistema operacional também consomem memória. Isso é buffering
limitado na aplicação, não uma forma de remover todo limite de memória.
PharData oferece uma API para manipular
arquivos tar e ZIP em disco. Usá-la não significa, por si só, carregar o conteúdo de todos os
arquivos em uma única string. Para este download de escrita única, um processo tar externo nos
dá um pipe simples e um status de saída da criação do arquivo tar.
Salve isto como setup.sh em um diretório de trabalho e execute bash setup.sh. Ele cria um novo
diretório php-tar-demo e se recusa a reutilizar um já existente. A entrada contém um arquivo de texto,
um arquivo vazio e bytes binários. Os arquivos seguintes ficam dentro de php-tar-demo.
#!/usr/bin/env bash
set -euo pipefail
mkdir php-tar-demo
cd php-tar-demo
mkdir public logs logs/build-123
printf 'Build 123 passed\n' > logs/build-123/build.log
: > logs/build-123/empty.log
printf '\000\001\177\200\377\n' > logs/build-123/payload.bin
Transmita a saída do tar com proc_open()
Salve isto como tar-streaming.php. A
forma de array de proc_open()
inicia o programa sem shell. Caminhos absolutos de diretório e -- impedem que nomes de arquivo
virem opções de comando. Use um PATH confiável no servidor e deixe TAR_OPTIONS indefinido.
<?php
declare(strict_types=1);
/** Both paths must already have been resolved with realpath(). */
function isWithin(string $path, string $root): bool
{
return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}
function safeAttachmentName(string $downloadName): string
{
$name = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($downloadName)), '._');
return $name === '' ? 'archive.tar' : substr($name, 0, 100);
}
function clearOutputBuffers(): void
{
while (ob_get_level() > 0) {
$status = ob_get_status();
if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0 || !ob_end_clean()) {
throw new RuntimeException('Cannot disable output buffering for this response.');
}
}
}
function streamTarArchive(string $directory, string $downloadName = 'archive.tar'): void
{
$realDir = realpath($directory);
if ($realDir === false || !is_dir($realDir)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
clearOutputBuffers();
// Discard stderr rather than risk a full, unread pipe blocking tar.
// The exit status still tells the caller whether creation succeeded.
$descriptors = [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['file', '/dev/null', 'w'],
];
$process = proc_open(['tar', '-C', $realDir, '-cf', '-', '--', '.'], $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Could not start tar.');
}
// Let our cleanup run when PHP notices a disconnected client.
$previousIgnoreAbort = ignore_user_abort(true);
$reachedEnd = false;
try {
header('Content-Type: application/x-tar');
header('Content-Disposition: attachment; filename="' . safeAttachmentName($downloadName) . '"');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
header('X-Accel-Buffering: no');
while (true) {
$chunk = fread($pipes[1], 8192);
if ($chunk === false) {
throw new RuntimeException('Failed to read the tar output stream.');
}
if ($chunk === '') {
break;
}
echo $chunk;
flush();
if (connection_aborted()) {
throw new RuntimeException('Client disconnected.');
}
}
$reachedEnd = true;
} finally {
fclose($pipes[1]);
if (!$reachedEnd) {
proc_terminate($process);
}
$exitCode = proc_close($process);
ignore_user_abort($previousIgnoreAbort !== 0);
}
if ($exitCode !== 0) {
throw new RuntimeException("tar exited with status {$exitCode}");
}
}
class SecureTarStreamer
{
private array $allowedRoots;
public function __construct(array $allowedRoots)
{
$this->allowedRoots = array_map(function (string $root): string {
$resolved = realpath($root);
if ($resolved === false || !is_dir($resolved)) {
throw new InvalidArgumentException('Invalid allowed root directory.');
}
return $resolved;
}, $allowedRoots);
}
public function send(string $directory, string $downloadName = 'archive.tar'): void
{
$path = realpath($directory);
if ($path === false || !is_dir($path)) {
throw new InvalidArgumentException('Directory does not exist or is not accessible.');
}
foreach ($this->allowedRoots as $root) {
if (isWithin($path, $root)) {
streamTarArchive($path, $downloadName);
return;
}
}
throw new RuntimeException('Access to the specified directory is not allowed.');
}
}
O pipe é bloqueante, então uma leitura vazia significa EOF; false significa falha de leitura. Depois
de fechar o pipe, proc_close() espera o produtor
terminar e retorna o status de saída dele. Nem o EOF nem a última escrita bem-sucedida indicam que
tar teve sucesso. Descartar o stderr significa abrir mão de diagnósticos detalhados; se você
precisar deles, drene stdout e stderr simultaneamente e retenha apenas uma quantidade limitada de
texto de diagnóstico.
Sirva um diretório de logs de build concluídos
Salve isto como public/download.php. O log_id opaco seleciona um diretório configurado; a requisição
nunca fornece um caminho do sistema de arquivos. Apenas public/ é servido, o que mantém os logs de
origem e o helper fora do document root.
<?php
declare(strict_types=1);
require __DIR__ . '/../tar-streaming.php';
try {
$logId = $_GET['log_id'] ?? '';
$directories = ['build-123' => __DIR__ . '/../logs/build-123'];
if (!is_string($logId) || !isset($directories[$logId])) {
throw new InvalidArgumentException('Unknown log identifier.');
}
$streamer = new SecureTarStreamer([__DIR__ . '/../logs']);
$streamer->send($directories[$logId], $logId . '.tar');
error_log('Archive generation completed.');
} catch (Throwable $error) {
error_log('Archive generation failed: ' . $error->getMessage());
if (!headers_sent()) {
header_remove('Content-Disposition');
http_response_code($error instanceof InvalidArgumentException ? 400 : 500);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: private, no-store');
echo "The archive could not be generated.\n";
}
// After headers, adding an error message would contaminate the archive bytes.
}
Reforce a segurança das chamadas a proc_open()
Este exemplo local não tem autenticação. Antes de montá-lo em uma aplicação, exija login e
autorização para o build selecionado. Mantenha os diretórios arquivados sob controle da aplicação e
pare primeiro os processos que gravam neles. Resolver a raiz bloqueia um diretório irmão como
logs-evil, mas não congela a árvore contra alterações feitas por outro processo. O comando não segue
entradas de symlink; o conteúdo do destino de um arquivo vinculado não é incluído. Use arquivos e
diretórios comuns neste exemplo, não sockets nem dispositivos especiais.
Salve isto como serve.sh e execute bash serve.sh a partir de php-tar-demo. Ele roda em primeiro plano;
pare-o com Ctrl+C. Se a porta 8080 estiver ocupada, use PORT=8090 bash serve.sh e defina o mesmo PORT ao
executar o script cliente abaixo. Se o bind falhar, o processo termina com erro.
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f public/download.php ]; then
printf 'Run serve.sh from the project directory after saving public/download.php.\n' >&2
exit 1
fi
exec env -u TAR_OPTIONS php \
-d memory_limit=32M -d output_buffering=0 -d zlib.output_compression=0 \
-d display_errors=0 -d log_errors=1 \
-S "127.0.0.1:${PORT:-8080}" -t public
O servidor embutido do PHP serve para
desenvolvimento local. Em uma implantação existente com PHP-FPM, desative também o buffering de
saída e a compressão do PHP, mantenha display_errors desligado e configure timeouts de requisição e de
upstream. O flush() do PHP não consegue
sobrescrever todos os buffers mais adiante na cadeia. Com Nginx, use fastcgi_buffering off e
fastcgi_ignore_client_abort off para esta rota. O Nginx também reconhece o cabeçalho
X-Accel-Buffering: no
do helper, a menos que esteja configurado para ignorá-lo. Uma leitura bloqueada do sistema de
arquivos ou uma escrita de saída bloqueada ainda pode atrasar a limpeza do PHP; não trate este loop
como um limite de tempo absoluto nem como uma tarefa em segundo plano persistente.
Verifique o que o cliente realmente recebeu
Salve isto como verify.sh. Em um segundo terminal dentro de php-tar-demo, execute bash verify.sh.
Ele faz o download, verifica a lista exata de membros, extrai a fixture local e compara os três
arquivos com os originais. Ele cria downloaded/ uma única vez e se recusa a sobrescrevê-lo em uma nova
execução. Se uma etapa falhar, esse diretório permanece para inspeção, possivelmente com um arquivo
tar parcial; use um novo projeto vazio para outra execução.
#!/usr/bin/env bash
set -euo pipefail
mkdir downloaded
curl -fsS --max-time 30 \
"http://127.0.0.1:${PORT:-8080}/download.php?log_id=build-123" \
--output downloaded/build.tar
tar -tf downloaded/build.tar | LC_ALL=C sort > downloaded/members.txt
printf './\n./build.log\n./empty.log\n./payload.bin\n' | cmp - downloaded/members.txt
tar -xf downloaded/build.tar -C downloaded
cmp logs/build-123/build.log downloaded/build.log
cmp logs/build-123/empty.log downloaded/empty.log
cmp logs/build-123/payload.bin downloaded/payload.bin
printf 'All expected members and bytes match.\n'
A última linha só aparece depois que as comparações passam. Apenas listar o arquivo tar é uma verificação mais fraca. O GNU tar pode terminar de gravar um arquivo tar enquanto relata um erro de leitura, deixando de fora o membro ilegível. Seus status de saída documentados separam a criação bem-sucedida de entradas alteradas e de falhas, mas esse status do produtor não é codificado no corpo da resposta HTTP.
Por exemplo, se o worker do PHP não conseguir ler payload.bin, a rota pode registrar uma falha do tar
enquanto o cURL termina com sucesso e tar -tf lista os membros restantes sem erro. A comparação da
lista de membros acima detecta a omissão porque tem uma expectativa independente. Com uma exportação
remota, o destinatário precisa de um inventário e de hashes de conteúdo fornecidos de forma
independente para fazer esse tipo de verificação; derivar um inventário do arquivo tar recebido não
prova nada sobre arquivos de origem ausentes.
E os downloads interrompidos?
Uma conexão interrompida pode deixar um arquivo tar parcial. Já uma falha tardia do produtor pode
deixar um arquivo tar que pode ser lido, mas está incompleto. Depois que o PHP envia os cabeçalhos,
headers_sent() impede que o handler
substitua o HTTP 200 por um status de erro. O handler registra a falha e não adiciona texto ao
arquivo tar. Ele não pode garantir que o cURL ou um navegador vá relatar um download com falha.
O helper verifica connection_aborted()
depois de escrever, então fecha o pipe e solicita que tar termine. A detecção depende de o PHP
e o servidor web perceberem a desconexão. Mesmo um log de conclusão no servidor significa apenas que
a geração terminou sem um erro de transferência detectado, não que o cliente salvou o arquivo.
Este endpoint não implementa requisições HTTP de intervalo (range requests). Tentar de novo inicia um arquivo tar novo, que pode ter bytes diferentes se a origem tiver mudado. Retomar exige servir a mesma representação estável do arquivo tar; o layout sequencial do tar não impede, por si só, intervalos de bytes HTTP.
Informe o progresso a partir da mesma tarefa
Um segundo processo tar -v mede uma execução diferente, mesmo que leia o mesmo diretório. Não use
isso como sinal de progresso ou de conclusão do download. Um endpoint SSE separado precisa de um
identificador de tarefa e de um estado compartilhado gravado pelo produtor real. Mantenha o status de
criação de uma tarefa separado do status de transferência do cliente. Este exemplo síncrono não tem
um armazenamento de progresso nem um comprovante de conclusão para o cliente.
Compare as abordagens
O streaming direto é adequado para um download em que evitar o armazenamento temporário do arquivo tar é importante e quem faz a chamada entende a limitação da falha tardia. Para um backup ou uma exportação que precisa relatar falhas de criação antes de o download começar, gere o conteúdo em um arquivo temporário privado, verifique o status de saída do produtor e publique apenas um resultado bem-sucedido. Isso ainda permite memória limitada no PHP, mas exige espaço em disco para o arquivo tar.
Sirva o arquivo concluído e imutável com seu tamanho conhecido e um checksum quando os destinatários precisarem verificar a integridade da transferência ou retomá-la. Verificar o produtor primeiro resolve o momento em que os erros de criação aparecem; isso não transforma arquivos de origem em mudança em um snapshot consistente. Congele as entradas ou use um snapshot do sistema de arquivos quando essa consistência fizer parte da promessa da exportação.
