Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Fixed

- Use a unique temporary file when exporting import errors to CSV

## [2.15.11] - 2026-09-11

### Fixed
Expand Down
91 changes: 72 additions & 19 deletions inc/clientinjection.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use Glpi\Application\View\TemplateRenderer;
use Glpi\Debug\Profile;
use Glpi\Error\ErrorHandler;
use Safe\Exceptions\FilesystemException;
use Safe\Exceptions\InfoException;

use function Safe\fclose;
Expand All @@ -40,6 +41,8 @@
use function Safe\json_decode;
use function Safe\json_encode;
use function Safe\readfile;
use function Safe\realpath;
use function Safe\tempnam;
use function Safe\unlink;

class PluginDatainjectionClientInjection
Expand Down Expand Up @@ -334,35 +337,85 @@ private static function escapeCsvFormula(mixed $value): mixed
return $value;
}

public static function exportErrorsInCSV()
private static function writeErrorsCsv(string $dir, array $error_lines, array $headers, string $delimiter): string
{
$upload_dir = realpath($dir);
// tempnam() silently falls back to the system temp dir when the target dir is unusable
if (!is_writable($upload_dir)) {
throw new FilesystemException(sprintf('Upload directory "%s" is not writable.', $dir));
}

$error_lines = json_decode(PluginDatainjectionSession::getParam('error_lines'), true);
self::stripslashes_array($error_lines);

if (!in_array($error_lines, ['', '0', []], true)) {
$model = PluginDatainjectionSession::unserialize(PluginDatainjectionSession::getParam('currentmodel'));
$file = PLUGIN_DATAINJECTION_UPLOAD_DIR . basename((string) PluginDatainjectionSession::getParam('file_name'));

$mappings = $model->getMappings();
$tmpfile = fopen($file, 'w');
$file = tempnam($upload_dir, 'ERR');
$tmpfile = null;
try {
$tmpfile = fopen($file, 'w');

//If headers present
if ($model->getBackend()->isHeaderPresent()) {
$headers = PluginDatainjectionMapping::getMappingsSortedByRank($model->fields['id']);
fputcsv($tmpfile, $headers, $model->getBackend()->getDelimiter());
if ($headers !== []) {
fputcsv($tmpfile, $headers, $delimiter);
}

//Write lines
foreach ($error_lines as $line) {
fputcsv($tmpfile, array_map(self::escapeCsvFormula(...), $line), $model->getBackend()->getDelimiter());
fputcsv($tmpfile, array_map(self::escapeCsvFormula(...), $line), $delimiter);
}

fclose($tmpfile);
} catch (FilesystemException $filesystemException) {
self::discardErrorsCsv($tmpfile, $file);
throw $filesystemException;
}

return $file;
}

/**
* @param resource|null $handle
*/
private static function discardErrorsCsv($handle, string $file): void
{
// Cleanup failures are swallowed so they never mask the original write error
try {
if (is_resource($handle)) {
fclose($handle);
}
} catch (FilesystemException) {
}

if (!file_exists($file)) {
return;
}

try {
unlink($file);
} catch (FilesystemException) {
}
}

public static function exportErrorsInCSV()
{

$error_lines = json_decode(PluginDatainjectionSession::getParam('error_lines'), true);
self::stripslashes_array($error_lines);

if (!in_array($error_lines, ['', '0', []], true)) {
$model = PluginDatainjectionSession::unserialize(PluginDatainjectionSession::getParam('currentmodel'));
$backend = $model->getBackend();
$headers = $backend->isHeaderPresent()
? PluginDatainjectionMapping::getMappingsSortedByRank($model->fields['id'])
: [];

try {
$file = self::writeErrorsCsv(PLUGIN_DATAINJECTION_UPLOAD_DIR, $error_lines, $headers, $backend->getDelimiter());
} catch (FilesystemException $e) {
ErrorHandler::logCaughtException($e);
Session::addMessageAfterRedirect(
__s('Unable to generate the error file', 'datainjection'),
false,
ERROR,
);
Html::back();
}

$name = "Error-" . basename((string) PluginDatainjectionSession::getParam('file_name'));
$name = str_replace(' ', '', $name);
header('Content-disposition: attachment; filename=' . $name);
header('Content-disposition: attachment; filename=Errors.csv');
header('Content-Type: application/octet-stream');
header('Content-Transfer-Encoding: fichier');
header('Content-Length: ' . filesize($file));
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/ClientInjectionWriteErrorsCsvTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

/**
* -------------------------------------------------------------------------
* DataInjection plugin for GLPI
* -------------------------------------------------------------------------
*
* LICENSE
*
* This file is part of DataInjection.
*
* DataInjection is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* DataInjection is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with DataInjection. If not, see <http://www.gnu.org/licenses/>.
* -------------------------------------------------------------------------
* @copyright Copyright (C) 2007-2023 by DataInjection plugin team.
* @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html
* @link https://github.com/pluginsGLPI/datainjection
* -------------------------------------------------------------------------
*/

namespace GlpiPlugin\Datainjection\Tests\Unit;

use Glpi\Tests\DbTestCase;
use PluginDatainjectionClientInjection;
use ReflectionMethod;
use Safe\Exceptions\FilesystemException;

require_once dirname(__DIR__, 2) . '/inc/clientinjection.class.php';

final class ClientInjectionWriteErrorsCsvTest extends DbTestCase
{
/** @var string[] */
private array $created_files = [];

public function tearDown(): void
{
foreach ($this->created_files as $created_file) {
if (file_exists($created_file)) {
unlink($created_file);
}
}

$this->created_files = [];

parent::tearDown();
}

private function writeErrorsCsv(array $error_lines, array $headers, string $dir = PLUGIN_DATAINJECTION_UPLOAD_DIR): string
{
$write_errors_csv = new ReflectionMethod(PluginDatainjectionClientInjection::class, 'writeErrorsCsv');
$file = $write_errors_csv->invoke(null, $dir, $error_lines, $headers, ';');
$this->created_files[] = $file;

return $file;
}

public function testFileIsCreatedInUploadDirWithErrPrefix(): void
{
$file = $this->writeErrorsCsv([['a', 'b']], []);

$this->assertSame(realpath(PLUGIN_DATAINJECTION_UPLOAD_DIR), realpath(dirname($file)));
$this->assertStringStartsWith('ERR', basename($file));
}

public function testMissingDirDoesNotFallBackToSystemTempDir(): void
{
$this->expectException(FilesystemException::class);

$this->writeErrorsCsv([['a']], [], PLUGIN_DATAINJECTION_UPLOAD_DIR . '/missing_dir');
}

public function testEachCallUsesADistinctFile(): void
{
$first = $this->writeErrorsCsv([['a']], []);
$second = $this->writeErrorsCsv([['a']], []);

$this->assertNotSame($first, $second);
}

public function testContentContainsHeadersAndEscapedLines(): void
{
$file = $this->writeErrorsCsv([['=SUM(A1)', 'safe']], ['name', 'serial']);

$this->assertSame("name;serial\n'=SUM(A1);safe\n", file_get_contents($file));
}

public function testDiscardClosesHandleAndRemovesFile(): void
{
$file = $this->writeErrorsCsv([['a']], []);
$handle = fopen($file, 'r');

$this->discardErrorsCsv($handle, $file);

$this->assertFalse(is_resource($handle));
$this->assertFileDoesNotExist($file);
}

public function testDiscardIgnoresCleanupFailures(): void
{
$missing_file = PLUGIN_DATAINJECTION_UPLOAD_DIR . '/missing_file.csv';

$this->discardErrorsCsv(null, $missing_file);

$this->assertFileDoesNotExist($missing_file);
}

private function discardErrorsCsv($handle, string $file): void
{
$discard_errors_csv = new ReflectionMethod(PluginDatainjectionClientInjection::class, 'discardErrorsCsv');
$discard_errors_csv->invoke(null, $handle, $file);
}
}
Loading