ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
AtomicFileWriterTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
24use PHPUnit\Framework\Attributes\DataProvider;
25use PHPUnit\Framework\TestCase;
26use RuntimeException;
27
32class AtomicFileWriterTest extends TestCase
33{
34 private string $directory;
35
36 protected function setUp(): void
37 {
38 $this->directory = sys_get_temp_dir() . '/ilias_atomic_' . bin2hex(random_bytes(4));
39 mkdir($this->directory, 0775);
40 }
41
42 protected function tearDown(): void
43 {
45 }
46
50 private function directoryListing(): array
51 {
52 return array_values(array_diff(scandir($this->directory) ?: [], ['.', '..']));
53 }
54
56 {
57 AtomicFileWriter::write($this->directory . '/target.po', "inhalt\n");
58
59 $this->assertSame("inhalt\n", file_get_contents($this->directory . '/target.po'));
60 $this->assertSame(['target.po'], $this->directoryListing());
61 }
62
63 public function testReplacesAnExistingFile(): void
64 {
65 file_put_contents($this->directory . '/target.po', 'alt');
66
67 AtomicFileWriter::write($this->directory . '/target.po', 'neu');
68
69 $this->assertSame('neu', file_get_contents($this->directory . '/target.po'));
70 $this->assertSame(['target.po'], $this->directoryListing());
71 }
72
73 public function testWritesEmptyContent(): void
74 {
75 AtomicFileWriter::write($this->directory . '/empty.mo', '');
76
77 $this->assertSame('', file_get_contents($this->directory . '/empty.mo'));
78 }
79
86 {
87 AtomicFileWriter::write($this->directory . '/target.mo', 'inhalt', null, false);
88
89 $this->assertSame('inhalt', file_get_contents($this->directory . '/target.mo'));
90 $this->assertSame(['target.mo'], $this->directoryListing());
91 }
92
98 {
99 $outside = dirname($this->directory) . '/ilias_atomic_outside_' . bin2hex(random_bytes(4));
100 mkdir($outside, 0775);
101 try {
102 $this->expectException(RuntimeException::class);
103 AtomicFileWriter::write($outside . '/target.po', 'x', $this->directory, false);
104 } finally {
106 }
107 }
108
115 {
116 $previous = umask(0022);
117 try {
118 AtomicFileWriter::write($this->directory . '/target.mo', 'x');
119 } finally {
120 umask($previous);
121 }
122
123 clearstatcache();
124 $this->assertSame('0644', sprintf('%04o', fileperms($this->directory . '/target.mo') & 0777));
125 }
126
135 {
136 file_put_contents($this->directory . '/target.po', 'alt');
137 chmod($this->directory . '/target.po', 0600);
138 $previous_umask = umask(0022); // would produce 0644 for a NEW file - must not leak in here
139 try {
140 AtomicFileWriter::write($this->directory . '/target.po', 'neu');
141 } finally {
142 umask($previous_umask);
143 }
144
145 clearstatcache();
146 $this->assertSame('0600', sprintf('%04o', fileperms($this->directory . '/target.po') & 0777));
147 }
148
153 #[DataProvider('grantsAtLeastProvider')]
154 public function testGrantsAtLeast(int $effective_mode, int $intended_mode, bool $expected): void
155 {
156 $this->assertSame($expected, AtomicFileWriter::grantsAtLeast($effective_mode, $intended_mode));
157 }
158
162 public static function grantsAtLeastProvider(): array
163 {
164 return [
165 '0600 lacks group/other read the intended 0644 needs' => [0600, 0644, false],
166 '0777 grants everything, including 0644' => [0777, 0644, true],
167 '0644 grants exactly the identical 0644 it is compared against' => [0644, 0644, true],
168 '0640 lacks group-write and other-read the intended 0664 needs' => [0640, 0664, false],
169 '0664 grants everything the less permissive 0644 needs' => [0664, 0644, true],
170 ];
171 }
172
178 {
179 AtomicFileWriter::write($this->directory . '/target.po', 'x', $this->directory);
180
181 $this->assertSame('x', file_get_contents($this->directory . '/target.po'));
182 $this->assertSame(['target.po'], $this->directoryListing());
183 }
184
190 {
191 $outside = dirname($this->directory) . '/ilias_atomic_outside_' . bin2hex(random_bytes(4));
192 mkdir($outside, 0775);
193 $confine_to = $this->directory . '/allowed';
194 mkdir($confine_to, 0775);
195
196 try {
197 try {
198 AtomicFileWriter::write($outside . '/target.po', 'x', $confine_to);
199 $this->fail('Expected a RuntimeException');
200 } catch (RuntimeException $e) {
201 $this->assertStringContainsString('does not resolve to a path below', $e->getMessage());
202 }
203
204 $this->assertSame([], array_values(array_diff(scandir($outside) ?: [], ['.', '..'])), 'nothing must be written outside the confined directory');
205 $this->assertSame([], array_values(array_diff(scandir($confine_to) ?: [], ['.', '..'])), 'no temporary file must leak into the confined directory either');
206 } finally {
208 }
209 }
210
211 // NOT COVERED: the post-rename confinement re-check in write(). It fires only if a symlink below the confined
212 // directory is swapped while the single synchronous write() runs (the accepted TOCTOU risk of the class comment).
213 // write() is final and static with no injectable seam and no blocking primitive (flock()) to synchronize a racing
214 // process against, so it cannot be reproduced deterministically.
215
216 // NOT COVERED: acceptUnchangeableMode()'s branching on a failed chmod() (effective mode still grants enough vs.
217 // RuntimeException). There is no injectable seam, and a chmod() failure that leaves the write intact cannot be
218 // simulated: `chattr +i` is refused on both the host and the container filesystem.
219
221 {
222 set_error_handler(static fn(): bool => true, E_NOTICE | E_WARNING);
223 try {
224 $this->expectException(RuntimeException::class);
225 $this->expectExceptionMessageMatches('/Could not create a temporary file/');
226 AtomicFileWriter::write($this->directory . '/missing/target.po', 'x');
227 } finally {
228 restore_error_handler();
229 }
230 }
231
237 {
238 mkdir($this->directory . '/target.po');
239
240 set_error_handler(static fn(): bool => true, E_WARNING);
241 try {
242 AtomicFileWriter::write($this->directory . '/target.po', 'x');
243 $this->fail('Expected a RuntimeException');
244 } catch (RuntimeException $e) {
245 $this->assertStringContainsString('Could not replace', $e->getMessage());
246 } finally {
247 restore_error_handler();
248 }
249
250 $this->assertSame(['target.po'], $this->directoryListing());
251 $this->assertDirectoryExists($this->directory . '/target.po');
252 }
253
258 public function testWritesIntoASymlinkedDirectory(): void
259 {
260 $real = $this->directory . '/real';
261 mkdir($real);
262 symlink($real, $this->directory . '/link');
263
264 AtomicFileWriter::write($this->directory . '/link/target.po', 'x');
265
266 $this->assertSame('x', file_get_contents($real . '/target.po'));
267 $this->assertSame(['target.po'], array_values(array_diff(scandir($real), ['.', '..'])));
268 }
269
271 {
272 mkdir($this->directory . '/sub');
273
274 AtomicFileWriter::write($this->directory . '//sub//target.po', 'x');
275
276 $this->assertSame('x', file_get_contents($this->directory . '/sub/target.po'));
277 $this->assertSame(['target.po'], array_values(array_diff(scandir($this->directory . '/sub'), ['.', '..'])));
278 }
279
280 public function testWritesToARelativePath(): void
281 {
282 $cwd = getcwd();
283 chdir($this->directory);
284 try {
285 AtomicFileWriter::write('relative.po', 'x');
286 mkdir('nested');
287 AtomicFileWriter::write('nested/../nested/target.po', 'y');
288 } finally {
289 chdir($cwd);
290 }
291
292 $this->assertSame('x', file_get_contents($this->directory . '/relative.po'));
293 $this->assertSame('y', file_get_contents($this->directory . '/nested/target.po'));
294 $this->assertSame(['nested', 'relative.po'], $this->directoryListing());
295 }
296
301 {
302 file_put_contents($this->directory . '/file', 'x');
303
304 set_error_handler(static fn(): bool => true, E_NOTICE | E_WARNING);
305 try {
306 $this->expectException(RuntimeException::class);
307 AtomicFileWriter::write($this->directory . '/file/target.po', 'x');
308 } finally {
309 restore_error_handler();
310 }
311 }
312}
Writes a file so that concurrent readers only see the complete old or new content: a temporary file i...
static grantsAtLeast(int $effective_mode, int $intended_mode)
Whether $effective_mode (permission bits 07777 only) grants every permission of $intended_mode.
static write(string $file, string $content, ?string $confine_to_directory=null, bool $flush_to_disk=true)
AtomicFileWriter: the overlay .po/.mo are replaced via a temporary file in the same directory plus re...
testConfineToDirectoryAllowsAWriteWhoseTargetResolvesInsideIt()
The happy path of $confine_to_directory: a target resolving inside it is written normally - both the ...
testThrowsWhenAPathComponentIsARegularFile()
Writing below a regular file (not a directory) must fail cleanly - also as root.
testAnExistingTargetKeepsItsOwnModeInsteadOfANewFilesMode()
An existing target keeps its own mode - not the "new file" mode from testTheWrittenFileDoesNotKeepThe...
testFlushToDiskDisabledStillHonoursConfineToDirectory()
$flush_to_disk = false must not disable the $confine_to_directory guard - both parameters are indepen...
testThrowsWhenTheTargetCannotBeReplacedAndRemovesTheTemporaryFile()
Root-proof failure of the final rename(): the target is a directory.
testWritesIntoASymlinkedDirectory()
Regression: tempnam() returns the resolved directory; comparing it with the unresolved one rejected e...
testTheWrittenFileDoesNotKeepTheRestrictiveTempnamPermissions()
tempnam() creates 0600 files; the result must have the permissions a plain file_put_contents() would ...
testConfineToDirectoryThrowsWhenTheTargetResolvesOutsideIt()
Mutation: the pre-write confinement check dropped, inverted or applied after writing - a target whose...
testGrantsAtLeast(int $effective_mode, int $intended_mode, bool $expected)
grantsAtLeast() is the pure decision acceptUnchangeableMode() delegates to: whether every permission ...
testWritesCompleteContentWithFlushToDiskDisabled()
$flush_to_disk = false (used by ShippedLanguageFilesCompiledObjective for the build artifacts) skips ...
static removeDirectory(string $directory)