19declare(strict_types=1);
27use PHPUnit\Framework\TestCase;
42 private const string MODULE =
'stest';
50 protected function setUp(): void
56 if (!defined(
'ILIAS_ABSOLUTE_PATH')) {
57 define(
'ILIAS_ABSOLUTE_PATH', realpath(__DIR__ .
'/../../../../../'));
60 $this->fixture_directory = __DIR__ .
'/tmp-symlink-fixtures-' . bin2hex(random_bytes(4));
61 mkdir($this->fixture_directory, 0775,
true);
62 $this->client_data_dir = sys_get_temp_dir() .
'/ilias_mlfs_symlink_test_' . bin2hex(random_bytes(4));
63 mkdir($this->client_data_dir, 0775,
true);
64 $this->decoy_directory = sys_get_temp_dir() .
'/ilias_mlfs_symlink_decoy_' . bin2hex(random_bytes(4));
65 mkdir($this->decoy_directory, 0775,
true);
71 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) .
'/sub/'
95 return $this->fixture_directory .
'/sub/' . self::MODULE .
'_de.po';
108 private function sync(array $entries = [
'greeting' =>
'Hallo']): void
110 MigratedLanguageFileSync::sync(
116 $this->client_data_dir
125 return array_values(array_diff(scandir($this->decoy_directory) ?: [], [
'.',
'..']));
135 mkdir(dirname(dirname($this->
overlayBase())), 0775,
true);
136 symlink($this->decoy_directory, dirname($this->
overlayBase()));
140 $this->
sync([
'greeting' =>
'Servus']);
141 $this->fail(
'Expected a RuntimeException');
142 }
catch (RuntimeException
$e) {
143 $this->assertStringContainsString(
'symbolic link',
$e->getMessage());
146 $this->assertSame([], $this->
decoyListing(),
'nothing may be written into the linked-to directory');
147 $this->assertSame($this->decoy_directory, readlink(dirname($this->
overlayBase())),
'the symlink itself must be left in place');
158 $decoy = $this->decoy_directory .
'/decoy.po';
159 file_put_contents($decoy,
'DECOY');
164 $this->fail(
'Expected a RuntimeException');
165 }
catch (RuntimeException
$e) {
166 $this->assertStringContainsString(
'symbolic link',
$e->getMessage());
169 $this->assertSame(
'DECOY', file_get_contents($decoy),
'the file the link points to must not be modified');
170 $this->assertSame($decoy, readlink($this->
overlayBase() .
'.po'),
'the symlink itself must not be replaced');
182 $decoy = $this->decoy_directory .
'/decoy.mo';
183 file_put_contents($decoy,
'DECOY');
188 $this->fail(
'Expected a RuntimeException');
189 }
catch (RuntimeException
$e) {
190 $this->assertStringContainsString(
'symbolic link',
$e->getMessage());
193 $this->assertSame(
'DECOY', file_get_contents($decoy));
195 $this->assertFileDoesNotExist($this->
overlayBase() .
'.po',
'the .po must not have been written either');
207 mkdir(dirname($this->
lockFile()), 0775,
true);
208 $decoy = $this->decoy_directory .
'/decoy.lock';
209 file_put_contents($decoy,
'DECOY');
212 $this->
sync([
'greeting' =>
'Servus']);
214 $this->assertSame(
'DECOY', file_get_contents($decoy),
'the lock file link target must never be opened/locked');
215 $this->assertSame($decoy, readlink($this->
lockFile()),
'the symlink itself must not be replaced');
217 [
'greeting' =>
'Servus'],
219 'the overlay write itself must still succeed even though no lock could be taken'
230 mkdir($overlay_dir, 0775,
true);
233 $decoy = $this->decoy_directory .
'/decoy.po';
234 file_put_contents($decoy,
'DECOY');
238 MigratedLanguageFileSync::removeOverlay($this->manager,
'de', self::MODULE, $this->client_data_dir);
239 $this->fail(
'Expected a RuntimeException');
240 }
catch (RuntimeException
$e) {
241 $this->assertStringContainsString(
'symbolic link',
$e->getMessage());
244 $this->assertSame(
'DECOY', file_get_contents($decoy));
245 $this->assertSame($decoy, readlink($this->
overlayBase() .
'.po'),
'the symlink must not have been unlinked');
256 $real_lang_dir = $this->client_data_dir .
'-real-lang';
257 mkdir($real_lang_dir, 0775,
true);
258 symlink($real_lang_dir, $this->client_data_dir .
'/lang');
260 $this->
sync([
'greeting' =>
'Servus']);
263 [
'greeting' =>
'Servus'],
265 'the write must go through the symlinked root into the real directory'
278 $outside = $this->decoy_directory .
'/outside.po';
281 AtomicFileWriter::write($outside,
'should not be written', $this->client_data_dir);
282 $this->fail(
'Expected a RuntimeException');
283 }
catch (RuntimeException
$e) {
284 $this->assertStringContainsString(
'does not resolve to a path below',
$e->getMessage());
287 $this->assertFileDoesNotExist($outside);
trait RestoresProcessState
Whatever a test does to the process (locale, LANGUAGE/LC_*, LOCPATH, the static state of the runtime ...
Writes a file so that concurrent readers only see the complete old or new content: a temporary file i...
The single place that knows where the files of a module migrated to PO/MO live:
Reads the shipped .po of a migrated module and maintains its per-installation overlay: the ....
MigratedLanguageFileSync follows no symlink below the overlay root (CWE-59, see assertNoSymbolicLinkB...
testASymlinkedIntermediateDirectoryIsRejectedAndItsTargetIsLeftUntouched()
Mutation: assertNoSymbolicLinkBelowOverlayRoot() not being called (or not checking every intermediate...
LanguageFileDirectoryManager $manager
testAtomicFileWriterRejectsATargetOutsideTheConfinedDirectory()
Mutation: AtomicFileWriter::write() not enforcing $confine_to_directory - a target outside of it (not...
seedShipped(string $value='Hallo')
testASymlinkedLockFileIsNeverFollowedAndTheOverlayIsStillWrittenUnlocked()
A symlinked lock file is no RuntimeException from sync(): acquireLock() catches it and runs $callback...
testRemoveOverlayRejectsASymlinkedPoAndLeavesItsTargetAndTheMoUntouched()
Mutation: removeOverlayFiles() unlinking ‘$base .
sync(array $entries=['greeting'=> 'Hallo'])
testASymlinkOnTheOverlayRootItselfIsAllowed()
The overlay root itself ("<client data dir>/lang") may be a symlink (e.g.
string $fixture_directory
testASymlinkedOverlayCurrentFileIsRejectedAndItsTargetIsLeftUntouched()
Mutation: the assertNoSymbolicLinkBelowOverlayRoot($client_data_dir, $overlay_mo) call being removed ...
testASymlinkedOverlayPoIsRejectedAndItsTargetIsLeftUntouched()
Mutation: the assertNoSymbolicLinkBelowOverlayRoot($client_data_dir, $overlay_po) call being removed ...
LanguageFileDirectory $directory
static directory(string $prefix, string $path, bool $local=false)
static catalog(string $module, array $entries)
static readMo(string $file)
static overlayCurrent(string $base_path)
static writePo(string $file, TranslationCatalog $catalog)
static removeDirectory(string $directory)
static overlayLock(string $base_path)
static overlayMo(string $base_path)
The compiled catalog of the revision current names; a non-existing path if there is none.
static overlayBase(string $client_data_dir, LanguageFileDirectory $directory, string $lang_key)
The overlay of $directory's module for $lang_key without the .po extension.
static resetRuntime(?string $artifact_directory=null)
Forgets the static state of the runtime read path and points build and runtime at $artifact_directory...
static writeOverlayRevision(string $base_path, TranslationCatalog $catalog)
Compiles $catalog into a revision of the overlay $base_path and points current to it,...
A directory of language files with a path and a prefix for them.