ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
MigratedLanguageFileSyncSymlinkTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
27use PHPUnit\Framework\TestCase;
28
39{
41
42 private const string MODULE = 'stest';
43
44 private string $fixture_directory;
45 private string $client_data_dir;
46 private string $decoy_directory;
49
50 protected function setUp(): void
51 {
52 // never the build of the installation, see MigratedPoFixture::resetRuntime()
55
56 if (!defined('ILIAS_ABSOLUTE_PATH')) {
57 define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../../'));
58 }
59
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);
66
67 // an extra "sub" segment below the fixture directory, so an intermediate directory can be replaced by a symlink
68 // without touching the fixture/client data directories
69 $this->directory = MigratedPoFixture::directory(
70 self::MODULE,
71 'components/ILIAS/Language/tests/ComponentTranslation/' . basename($this->fixture_directory) . '/sub/'
72 );
73 $this->manager = new LanguageFileDirectoryManager(new CustomizingLanguageFileDirectory(), $this->directory);
74 }
75
76 protected function tearDown(): void
77 {
78 MigratedPoFixture::removeDirectory($this->fixture_directory);
79 MigratedPoFixture::removeDirectory($this->client_data_dir);
80 MigratedPoFixture::removeDirectory($this->decoy_directory);
81
83 }
84
85 private function seedShipped(string $value = 'Hallo'): void
86 {
88 $this->shippedPo(),
89 MigratedPoFixture::catalog(self::MODULE, ['greeting' => $value])
90 );
91 }
92
93 private function shippedPo(): string
94 {
95 return $this->fixture_directory . '/sub/' . self::MODULE . '_de.po';
96 }
97
98 private function overlayBase(): string
99 {
100 return MigratedPoFixture::overlayBase($this->client_data_dir, $this->directory, 'de');
101 }
102
103 private function lockFile(): string
104 {
106 }
107
108 private function sync(array $entries = ['greeting' => 'Hallo']): void
109 {
110 MigratedLanguageFileSync::sync(
111 $this->manager,
112 ILIAS_ABSOLUTE_PATH,
113 'de',
114 self::MODULE,
115 $entries,
116 $this->client_data_dir
117 );
118 }
119
123 private function decoyListing(): array
124 {
125 return array_values(array_diff(scandir($this->decoy_directory) ?: [], ['.', '..']));
126 }
127
133 {
134 $this->seedShipped();
135 mkdir(dirname(dirname($this->overlayBase())), 0775, true);
136 symlink($this->decoy_directory, dirname($this->overlayBase()));
137
138 try {
139 // a local change: a shipped value alone writes nothing (delta overlay)
140 $this->sync(['greeting' => 'Servus']);
141 $this->fail('Expected a RuntimeException');
142 } catch (RuntimeException $e) {
143 $this->assertStringContainsString('symbolic link', $e->getMessage());
144 }
145
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');
148 }
149
155 {
156 $this->seedShipped();
157 mkdir(dirname($this->overlayBase()), 0775, true);
158 $decoy = $this->decoy_directory . '/decoy.po';
159 file_put_contents($decoy, 'DECOY');
160 symlink($decoy, $this->overlayBase() . '.po');
161
162 try {
163 $this->sync();
164 $this->fail('Expected a RuntimeException');
165 } catch (RuntimeException $e) {
166 $this->assertStringContainsString('symbolic link', $e->getMessage());
167 }
168
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');
171 }
172
179 {
180 $this->seedShipped();
181 mkdir(dirname($this->overlayBase()), 0775, true);
182 $decoy = $this->decoy_directory . '/decoy.mo';
183 file_put_contents($decoy, 'DECOY');
184 symlink($decoy, MigratedPoFixture::overlayCurrent($this->overlayBase()));
185
186 try {
187 $this->sync();
188 $this->fail('Expected a RuntimeException');
189 } catch (RuntimeException $e) {
190 $this->assertStringContainsString('symbolic link', $e->getMessage());
191 }
192
193 $this->assertSame('DECOY', file_get_contents($decoy));
194 $this->assertSame($decoy, readlink(MigratedPoFixture::overlayCurrent($this->overlayBase())));
195 $this->assertFileDoesNotExist($this->overlayBase() . '.po', 'the .po must not have been written either');
196 }
197
205 {
206 $this->seedShipped();
207 mkdir(dirname($this->lockFile()), 0775, true);
208 $decoy = $this->decoy_directory . '/decoy.lock';
209 file_put_contents($decoy, 'DECOY');
210 symlink($decoy, $this->lockFile());
211
212 $this->sync(['greeting' => 'Servus']);
213
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');
216 $this->assertSame(
217 ['greeting' => 'Servus'],
219 'the overlay write itself must still succeed even though no lock could be taken'
220 );
221 }
222
228 {
229 $overlay_dir = dirname($this->overlayBase());
230 mkdir($overlay_dir, 0775, true);
231 MigratedPoFixture::writeOverlayRevision($this->overlayBase(), MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Servus']));
232 file_put_contents(MigratedPoFixture::overlayMo($this->overlayBase()), 'REAL MO');
233 $decoy = $this->decoy_directory . '/decoy.po';
234 file_put_contents($decoy, 'DECOY');
235 symlink($decoy, $this->overlayBase() . '.po');
236
237 try {
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());
242 }
243
244 $this->assertSame('DECOY', file_get_contents($decoy));
245 $this->assertSame($decoy, readlink($this->overlayBase() . '.po'), 'the symlink must not have been unlinked');
246 $this->assertSame('REAL MO', file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())), 'the .mo must not have been removed either');
247 }
248
254 {
255 $this->seedShipped();
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');
259
260 $this->sync(['greeting' => 'Servus']);
261
262 $this->assertSame(
263 ['greeting' => 'Servus'],
265 'the write must go through the symlinked root into the real directory'
266 );
267
269 }
270
277 {
278 $outside = $this->decoy_directory . '/outside.po';
279
280 try {
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());
285 }
286
287 $this->assertFileDoesNotExist($outside);
288 }
289}
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...
testAtomicFileWriterRejectsATargetOutsideTheConfinedDirectory()
Mutation: AtomicFileWriter::write() not enforcing $confine_to_directory - a target outside of it (not...
testASymlinkedLockFileIsNeverFollowedAndTheOverlayIsStillWrittenUnlocked()
A symlinked lock file is no RuntimeException from sync(): acquireLock() catches it and runs $callback...
testRemoveOverlayRejectsASymlinkedPoAndLeavesItsTargetAndTheMoUntouched()
Mutation: removeOverlayFiles() unlinking ‘$base .
testASymlinkOnTheOverlayRootItselfIsAllowed()
The overlay root itself ("<client data dir>/lang") may be a symlink (e.g.
testASymlinkedOverlayCurrentFileIsRejectedAndItsTargetIsLeftUntouched()
Mutation: the assertNoSymbolicLinkBelowOverlayRoot($client_data_dir, $overlay_mo) call being removed ...
testASymlinkedOverlayPoIsRejectedAndItsTargetIsLeftUntouched()
Mutation: the assertNoSymbolicLinkBelowOverlayRoot($client_data_dir, $overlay_po) call being removed ...
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.