Diffuser des archives tar en PHP avec une mise en tampon bornée
Utilisez proc_open() pour lire la sortie de GNU tar un bloc à la fois et l’envoyer comme réponse HTTP.
PHP n’a pas besoin de conserver l’archive en mémoire ni d’écrire une archive temporaire. Il y a un
compromis : si tar échoue après le début de la réponse, le client peut recevoir un HTTP 200 et une
archive lisible à laquelle il manque des fichiers. Un téléchargement réussi ne prouve pas que
l’archive est complète.
Ce tutoriel crée un point de terminaison de téléchargement local pour un répertoire fixe de journaux
de builds terminés, puis vérifie chaque membre attendu et ses octets. Il a été testé sous Linux avec
le serveur local de PHP 8.5.10 et avec PHP-FPM 8.4.26 derrière Nginx 1.28.3, en utilisant GNU tar
1.35. Vous avez aussi besoin de Bash, de cURL et de cmp, avec proc_open() activé dans PHP. Windows et BSD tar sortent du cadre de ce tutoriel.
Garder l’archive hors des tampons de PHP
Dans PHP, memory_limit s’applique toujours. L’avantage ici est que la boucle de transfert ne conserve
qu’un bloc de 8 KiB plutôt qu’une chaîne de la taille de l’archive. Le processus tar distinct,
l’environnement d’exécution PHP, le serveur web et le système d’exploitation consomment aussi de la
mémoire. Il s’agit d’une mise en tampon applicative bornée, pas d’un moyen de supprimer toutes les
limites de mémoire.
PharData fournit une API pour manipuler
des archives tar et ZIP sur disque. L’utiliser n’implique pas en soi de charger le contenu de chaque
fichier dans une seule chaîne. Pour ce téléchargement écrit une seule fois, un processus tar externe
nous offre un tube simple et un code de sortie pour la création.
Enregistrez ceci sous setup.sh dans un répertoire de travail, puis exécutez bash setup.sh. Le script crée un
nouveau répertoire php-tar-demo et refuse d’en réutiliser un existant. L’entrée contient un fichier texte,
un fichier vide et des octets binaires. Les fichiers suivants se placent dans 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
Diffuser la sortie de tar avec proc_open()
Enregistrez ceci sous tar-streaming.php. La
forme tableau de proc_open()
lance le programme sans shell. Les chemins de répertoire absolus et -- empêchent les noms de
fichiers de devenir des options de commande. Utilisez côté serveur un PATH de confiance et laissez
TAR_OPTIONS non défini.
<?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.');
}
}
Le tube est bloquant, donc une lecture vide signifie EOF ; false signifie un échec de lecture. Après la
fermeture du tube, proc_close() attend le producteur
et renvoie son code de sortie. Ni EOF ni la dernière écriture réussie ne nous indiquent que tar
a réussi. Ignorer stderr vous prive de diagnostics détaillés ; si vous en avez besoin, videz stdout et
stderr simultanément et ne conservez qu’une quantité bornée de texte de diagnostic.
Servir un répertoire de journaux de builds terminés
Enregistrez ceci sous public/download.php. Le log_id opaque sélectionne un répertoire configuré ; la
requête ne fournit jamais de chemin de système de fichiers. Seul public/ est servi, ce qui garde les
journaux sources et le script utilitaire hors de la racine des documents.
<?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.
}
Renforcer les appels à proc_open()
Cet exemple local n’a pas d’authentification. Avant de l’intégrer dans une application, exigez à la
fois une connexion et une autorisation pour le build sélectionné. Gardez les répertoires archivés
sous le contrôle de l’application et arrêtez d’abord les processus qui y écrivent. Résoudre la racine
bloque un répertoire voisin tel que logs-evil, mais ne fige pas l’arborescence face à un autre processus
qui la modifierait. La commande ne suit pas les entrées de liens symboliques ; le contenu de la cible
d’un fichier lié n’est pas inclus. Utilisez des fichiers et des répertoires ordinaires pour cet
exemple, pas des sockets ni des périphériques spéciaux.
Enregistrez ceci sous serve.sh et exécutez bash serve.sh depuis php-tar-demo. Le serveur s’exécute au premier
plan ; arrêtez-le avec Ctrl+C. Si le port 8080 est occupé, utilisez PORT=8090 bash serve.sh et donnez à PORT la même
valeur lorsque vous exécutez le script client ci-dessous. En cas d’échec de liaison au port, le
processus se termine avec une erreur.
#!/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
Le serveur intégré de PHP est destiné
au développement local. Pour un déploiement PHP-FPM existant, désactivez aussi la mise en tampon de
sortie et la compression de PHP, laissez display_errors désactivé et configurez les délais d’expiration des
requêtes et de l’amont. Dans PHP,
flush() ne peut pas outrepasser tous les
tampons en aval. Avec Nginx, utilisez fastcgi_buffering off et fastcgi_ignore_client_abort off pour cette
route. Nginx reconnaît aussi l’en-tête
X-Accel-Buffering: no
du script utilitaire, sauf s’il est configuré pour l’ignorer. Une lecture bloquée du système de
fichiers ou une écriture de sortie bloquée peut encore retarder le nettoyage de PHP ; ne considérez
pas cette boucle comme une échéance en temps réel ni comme une tâche d’arrière-plan durable.
Vérifier ce que le client a réellement reçu
Enregistrez ceci sous verify.sh. Dans un second terminal, à l’intérieur de php-tar-demo, exécutez bash verify.sh.
Le script télécharge l’archive, vérifie la liste exacte des membres, extrait le jeu de données de
test local et compare les trois fichiers à leurs originaux. Il crée downloaded/ une seule fois et refuse de
l’écraser lors d’une nouvelle exécution. Si une étape échoue, ce répertoire reste disponible pour
inspection, éventuellement avec une archive partielle ; utilisez un nouveau projet vide pour une
autre exécution.
#!/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 dernière ligne n’apparaît qu’après la réussite des comparaisons. Se contenter de lister l’archive constitue une vérification plus faible. GNU tar peut terminer l’écriture d’une archive tout en signalant une erreur de lecture, en omettant le membre illisible. Ses codes de sortie documentés distinguent une création réussie des entrées modifiées et des échecs, mais ce code de sortie du producteur n’est pas encodé dans le corps de la réponse HTTP.
Par exemple, si le worker PHP ne peut pas lire payload.bin, la route peut journaliser un échec de tar alors
que cURL se termine avec succès et que tar -tf liste sans erreur les membres restants. La comparaison
de la liste des membres ci-dessus détecte l’omission, car elle repose sur une attente indépendante.
Avec un export distant, le destinataire a besoin d’un inventaire et de hachages du contenu fournis
indépendamment pour effectuer ce type de vérification ; dériver un inventaire de l’archive reçue ne
prouve rien sur les fichiers sources manquants.
Et les téléchargements interrompus ?
Une connexion interrompue peut laisser un fichier tar partiel. Un échec tardif du producteur peut
plutôt laisser une archive analysable mais incomplète. Une fois que PHP a envoyé les en-têtes,
headers_sent() empêche le gestionnaire
de remplacer HTTP 200 par un statut d’erreur. Le gestionnaire journalise l’échec et n’ajoute aucun
texte à l’archive. Il ne peut pas garantir que cURL ou un navigateur signalera un téléchargement
échoué.
Le script utilitaire vérifie connection_aborted()
après l’écriture, puis ferme le tube et demande l’arrêt de tar. La détection dépend du fait que PHP
et le serveur web remarquent la déconnexion. Même un journal de fin côté serveur signifie seulement
que la génération s’est terminée sans erreur de transfert détectée, et non que le client a
enregistré le fichier.
Ce point de terminaison n’implémente pas les requêtes HTTP de plage. Une nouvelle tentative démarre une nouvelle archive, dont les octets peuvent différer si la source a changé. La reprise nécessite de servir la même représentation d’archive stable ; la structure séquentielle de tar n’interdit pas en soi les plages d’octets HTTP.
Signaler la progression depuis la même tâche
Un second processus tar -v mesure une exécution différente, même s’il lit le même répertoire. Ne
l’utilisez pas comme signal de progression ou de fin du téléchargement. Un point de terminaison SSE
distinct a besoin d’un identifiant de tâche et d’un état partagé écrit par le producteur réel.
Séparez le statut de création d’une tâche du statut de transfert du client. Cet exemple synchrone ne
dispose ni d’un stockage de progression ni d’un accusé de fin pour le client.
Comparer les approches
La diffusion directe convient à un téléchargement pour lequel il importe d’éviter le stockage d’une archive temporaire et dont les appelants comprennent la limite liée aux échecs tardifs. Pour une sauvegarde ou un export qui doit signaler un échec de création avant le début du téléchargement, générez l’archive dans un fichier temporaire privé, vérifiez le code de sortie du producteur et ne publiez qu’un résultat réussi. Cette approche permet toujours une mémoire PHP bornée, mais nécessite de l’espace disque pour l’archive.
Servez le fichier terminé et immuable avec sa taille connue et une somme de contrôle lorsque les destinataires doivent vérifier l’intégrité du transfert ou le reprendre. Vérifier d’abord le producteur règle le moment où les erreurs de création apparaissent ; cela ne transforme pas des fichiers sources en cours de modification en un instantané cohérent. Figez les entrées ou utilisez un instantané du système de fichiers lorsque cette cohérence fait partie de la promesse de l’export.
