Transmite archivos tar en PHP con búferes acotados
Usa proc_open() para leer la salida de GNU tar por bloques y enviarla como respuesta HTTP.
PHP no necesita mantener el archivo tar en memoria ni escribir uno temporal. Hay una limitación:
si tar falla después de que comienza la respuesta, el cliente puede recibir HTTP 200
junto con un archivo tar legible al que le faltan archivos. Una descarga exitosa no demuestra que
el archivo tar esté completo.
Esta guía crea un endpoint local de descarga para un directorio fijo de registros de compilaciones
finalizadas y luego verifica cada elemento esperado y sus bytes. Se probó en Linux con el servidor
local de PHP 8.5.10 y con PHP-FPM 8.4.26 detrás de Nginx 1.28.3, usando GNU tar 1.35. También necesitas
Bash, cURL y cmp, con proc_open() habilitado en PHP.
Windows y BSD tar quedan fuera del alcance de esta guía.
Mantén el archivo tar fuera de los búferes de PHP
El límite memory_limit de PHP sigue vigente. La ventaja es que el bucle de transferencia
solo retiene un bloque de 8 KiB en lugar de una cadena del tamaño del archivo tar. El proceso
independiente tar, el entorno de ejecución de PHP, el servidor web y el sistema
operativo también consumen memoria. Esto acota el almacenamiento en búfer de la aplicación,
no elimina todos los límites de memoria.
PharData proporciona una API para manipular
archivos tar y ZIP en disco. Usarla no implica necesariamente cargar el contenido de cada archivo
en una sola cadena. Para esta descarga que se escribe una sola vez, un proceso externo
tar nos ofrece una tubería sencilla y un estado de salida de la creación.
Guarda lo siguiente como setup.sh en un directorio de trabajo y luego ejecuta
bash setup.sh. Crea un directorio nuevo php-tar-demo y se niega a reutilizar
uno existente. La entrada contiene un archivo de texto, un archivo vacío y bytes binarios.
Los archivos posteriores deben ir 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
Transmite la salida de tar con proc_open()
Guarda lo siguiente como tar-streaming.php. La
forma con array de proc_open()
inicia el programa sin un intérprete de comandos. Las rutas absolutas de los directorios y
-- evitan que los nombres de archivo se conviertan en opciones del comando.
Usa un PATH confiable del servidor y deja TAR_OPTIONS sin definir.
<?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.');
}
}
La tubería es bloqueante, por lo que una lectura vacía indica EOF; false indica
un fallo de lectura. Tras cerrar la tubería,
proc_close() espera al productor
y devuelve su estado de salida. Ni EOF ni la última escritura exitosa nos indican que
tar haya finalizado correctamente. Descartar stderr supone perder diagnósticos
detallados; si los necesitas, consume stdout y stderr de forma concurrente y conserva solo una
cantidad acotada de texto de diagnóstico.
Sirve un directorio de registros de compilaciones finalizadas
Guarda lo siguiente como public/download.php. El valor opaco log_id
selecciona un directorio configurado; la solicitud nunca proporciona una ruta del sistema de
archivos. Solo se sirve public/, de modo que los registros de origen y el código
auxiliar quedan fuera de la raíz de documentos.
<?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.
}
Refuerza la seguridad de las llamadas a proc_open()
Este ejemplo local no tiene autenticación. Antes de incorporarlo a una aplicación, exige tanto
el inicio de sesión como la autorización para la compilación seleccionada. Mantén bajo el control
de la aplicación los directorios que se incluirán en el archivo tar y detén primero los procesos
que escriben en ellos. Resolver la raíz bloquea un directorio hermano como
logs-evil, pero no inmoviliza el árbol para impedir que otro proceso lo modifique.
El comando no sigue las entradas de enlaces simbólicos; no se incluye el contenido del destino de
un archivo enlazado. Usa archivos y directorios ordinarios en este ejemplo, no sockets ni
dispositivos especiales.
Guarda lo siguiente como serve.sh y ejecuta bash serve.sh desde
php-tar-demo. Se ejecuta en primer plano; detenlo con Ctrl+C. Si el puerto 8080 está
ocupado, usa PORT=8090 bash serve.sh y establece el mismo PORT
al ejecutar el script del cliente que aparece más abajo. Si falla la vinculación al puerto,
el proceso termina con un error.
#!/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
El servidor integrado de PHP está destinado al desarrollo
local. En un despliegue existente de PHP-FPM, desactiva también el almacenamiento en búfer y la
compresión de la salida de PHP, mantén display_errors desactivado y configura los tiempos
de espera de las solicitudes y del servidor de origen. La función
flush() de PHP no puede anular todos los búferes
posteriores. Con Nginx, usa fastcgi_buffering off y fastcgi_ignore_client_abort off para esta
ruta. Nginx también reconoce el encabezado
X-Accel-Buffering: no
del código auxiliar, a menos que esté configurado para ignorarlo. Una lectura del sistema de
archivos o una escritura de salida bloqueadas aún pueden retrasar la limpieza de PHP; no trates
este bucle como un límite de tiempo real transcurrido ni como una tarea duradera en segundo plano.
Verifica qué recibió realmente el cliente
Guarda lo siguiente como verify.sh. En una segunda terminal, dentro de
php-tar-demo, ejecuta bash verify.sh.
El script descarga el archivo tar, verifica la lista exacta de elementos, extrae los datos de
prueba locales y compara los tres archivos con sus originales. Crea
downloaded/ una sola vez y se niega a sobrescribirlo en una nueva ejecución.
Si falla algún paso, ese directorio se conserva para su inspección, posiblemente con un archivo
tar parcial; usa un proyecto nuevo y vacío para otra ejecución.
#!/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'
La línea final solo aparece después de que las comparaciones se completan correctamente. Limitarse a listar el contenido del archivo tar es una verificación menos rigurosa. GNU tar puede terminar de escribir un archivo tar mientras informa de un error de lectura y omite el elemento ilegible. Sus estados de salida documentados distinguen la creación exitosa de los cambios en las entradas y de los fallos, pero ese estado del productor no está codificado en el cuerpo de la respuesta HTTP.
Por ejemplo, si el proceso de PHP no puede leer payload.bin, la ruta puede registrar
un fallo de tar mientras cURL termina correctamente y tar -tf lista sin errores
los elementos restantes. La comparación de la lista de elementos anterior detecta la omisión
porque dispone de una expectativa independiente. En una exportación remota, el destinatario
necesita un inventario y hashes del contenido proporcionados de forma independiente para realizar
ese tipo de verificación; derivar un inventario del archivo tar recibido no demuestra nada sobre
los archivos de origen que faltan.
¿Qué ocurre con las descargas interrumpidas?
Una conexión interrumpida puede dejar un archivo tar parcial. En cambio, un fallo tardío del
productor puede dejar un archivo tar que se pueda analizar, pero que esté incompleto. Una vez que
PHP ha enviado los encabezados,
headers_sent() impide que el controlador
reemplace HTTP 200 por un estado de error. El controlador registra el fallo y no añade texto al
archivo tar. No puede garantizar que cURL o un navegador informen de una descarga fallida.
El código auxiliar comprueba connection_aborted()
después de escribir, luego cierra la tubería y solicita que tar termine.
La detección depende de que PHP y el servidor web detecten la desconexión. Incluso un registro de
finalización del lado del servidor solo significa que la generación terminó sin que se detectara
un error de transferencia, no que el cliente haya guardado el archivo.
Este endpoint no implementa solicitudes de rangos HTTP. Cada reintento inicia un archivo tar nuevo, que puede contener bytes diferentes si el origen cambió. Reanudar la descarga requiere servir la misma representación estable del archivo tar; la disposición secuencial de tar no impide por sí misma el uso de rangos de bytes HTTP.
Informa del progreso de la misma tarea
Un segundo proceso tar -v mide una ejecución distinta, aunque lea el mismo
directorio. No lo uses como señal de progreso o finalización de la descarga. Un endpoint SSE
independiente necesita un identificador de tarea y un estado compartido escrito por el productor
real. Mantén separado el estado de creación de una tarea del estado de transferencia del cliente.
Este ejemplo síncrono no cuenta con un almacén de progreso ni con un acuse de finalización para
el cliente.
Compara los enfoques
La transmisión directa es adecuada para una descarga en la que importa evitar el almacenamiento temporal de archivos tar y quienes la solicitan comprenden la limitación de los fallos tardíos. Para una copia de seguridad o una exportación que deba informar de un fallo de creación antes de que comience la descarga, genera el contenido en un archivo temporal privado, comprueba el estado de salida del productor y publica solo un resultado exitoso. Esto también permite acotar el uso de memoria de PHP, pero requiere espacio en disco para el archivo tar.
Sirve el archivo terminado e inmutable con su longitud conocida y una suma de verificación cuando los destinatarios necesiten verificar la integridad de la transferencia o reanudarla. Comprobar primero el productor resuelve el problema del momento en que se notifican los errores de creación; no convierte los archivos de origen cambiantes en una instantánea consistente. Inmoviliza las entradas o usa una instantánea del sistema de archivos cuando esa consistencia forme parte de lo que promete la exportación.
