ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
ILIAS\Language\ComponentTranslation\AtomicFileWriter Class Reference

Writes a file so that concurrent readers only see the complete old or new content: a temporary file in the SAME directory (rename() is only atomic within one filesystem) replaces the target via rename(). More...

+ Collaboration diagram for ILIAS\Language\ComponentTranslation\AtomicFileWriter:

Static Public Member Functions

static write (string $file, string $content, ?string $confine_to_directory=null, bool $flush_to_disk=true)
 
static grantsAtLeast (int $effective_mode, int $intended_mode)
 Whether $effective_mode (permission bits 07777 only) grants every permission of $intended_mode. More...
 

Static Private Member Functions

static isAtOrBelow (string|false $resolved_path, string $resolved_root)
 
static acceptUnchangeableMode (string $file, int $mode)
 Some file systems do not support chmod(): that is only an error if the resulting mode lacks a permission $mode grants (e.g. More...
 
static writeContent (string $file, string $content, bool $flush_to_disk)
 Writes $content to $file and with $flush_to_disk flushes it, so the rename() cannot publish content that is not persisted yet. More...
 

Detailed Description

Writes a file so that concurrent readers only see the complete old or new content: a temporary file in the SAME directory (rename() is only atomic within one filesystem) replaces the target via rename().

The data is fsync()ed first unless the caller opts out; an existing target keeps its file mode.

Accepted residual risk (TOCTOU, CWE-367): PHP has no openat()/O_NOFOLLOW, so the confinement check and the later operations work on path names; whoever can replace a directory component of $file by a symbolic link in between (needs write access, i.e. the web server user) can redirect the write. After the rename() the directory is resolved again: outside $confine_to_directory the write throws and removes $file only if it is the file just written (same inode and device). Best effort. The same risk covers the overlay lock file created by MigratedLanguageFileSync::acquireLock().

Definition at line 37 of file AtomicFileWriter.php.

Member Function Documentation

◆ acceptUnchangeableMode()

static ILIAS\Language\ComponentTranslation\AtomicFileWriter::acceptUnchangeableMode ( string  $file,
int  $mode 
)
staticprivate

Some file systems do not support chmod(): that is only an error if the resulting mode lacks a permission $mode grants (e.g.

the web server could no longer read a Setup-written file), else it is logged.

Definition at line 144 of file AtomicFileWriter.php.

144 : void
145 {
146 clearstatcache(true, $file);
147 $effective = @fileperms($file);
148 if ($effective === false || !self::grantsAtLeast($effective, $mode)) {
149 throw new RuntimeException(sprintf(
150 'Could not set the mode of "%s" to %o, and its mode %s is more restrictive.',
151 $file,
152 $mode,
153 $effective === false ? 'unknown' : sprintf('%o', $effective & 07777)
154 ));
155 }
156 error_log(sprintf(
157 'Could not set the mode of "%s" to %o - keeping its mode %o, which grants at least as much.',
158 $file,
159 $mode,
160 $effective & 07777
161 ));
162 }

◆ grantsAtLeast()

static ILIAS\Language\ComponentTranslation\AtomicFileWriter::grantsAtLeast ( int  $effective_mode,
int  $intended_mode 
)
static

Whether $effective_mode (permission bits 07777 only) grants every permission of $intended_mode.

Decision of acceptUnchangeableMode(), public for tests only.

Definition at line 129 of file AtomicFileWriter.php.

129 : bool
130 {
131 return (($effective_mode & 07777) & $intended_mode) === $intended_mode;
132 }

Referenced by ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testGrantsAtLeast().

+ Here is the caller graph for this function:

◆ isAtOrBelow()

static ILIAS\Language\ComponentTranslation\AtomicFileWriter::isAtOrBelow ( string|false  $resolved_path,
string  $resolved_root 
)
staticprivate

Definition at line 134 of file AtomicFileWriter.php.

134 : bool
135 {
136 return $resolved_path !== false
137 && ($resolved_path === $resolved_root || str_starts_with($resolved_path, rtrim($resolved_root, '/') . '/'));
138 }

◆ write()

static ILIAS\Language\ComponentTranslation\AtomicFileWriter::write ( string  $file,
string  $content,
?string  $confine_to_directory = null,
bool  $flush_to_disk = true 
)
static
Parameters
string | null$confine_to_directory$file must resolve (its directory via realpath()) to this directory or below, so a symbolic link cannot redirect the write (CWE-59); checked before and after
bool$flush_to_diskfalse skips the fsync(): readers still see complete content, but after a crash the file may be lost. Only for reproducible files (e.g. build artifacts), as an fsync() per file is slow for thousands of them.
Exceptions
RuntimeExceptionif $file is a symbolic link, lies outside $confine_to_directory, or cannot be written

Definition at line 48 of file AtomicFileWriter.php.

53 : void {
54 if (is_link($file)) {
55 throw new RuntimeException(sprintf('Refusing to replace the symbolic link "%s".', $file));
56 }
57 $directory = dirname($file);
58 $resolved_root = null;
59 if ($confine_to_directory !== null) {
60 $resolved_root = realpath($confine_to_directory);
61 if ($resolved_root === false || !self::isAtOrBelow(realpath($directory), $resolved_root)) {
62 throw new RuntimeException(sprintf('"%s" does not resolve to a path below "%s".', $file, $confine_to_directory));
63 }
64 }
65 $temporary = @tempnam($directory, '.' . basename($file) . '.');
66 // tempnam() returns a resolved path - compare resolved directories, so symlinks, "//" or
67 // relative paths in $file do not count as a fallback to the system temp dir
68 $resolved_directory = realpath($directory);
69 if (
70 $temporary === false
71 || $resolved_directory === false
72 || realpath(dirname($temporary)) !== $resolved_directory
73 || ($resolved_root !== null && !self::isAtOrBelow($resolved_directory, $resolved_root))
74 ) {
75 // tempnam() silently falls back to the system temp dir if $directory is not writable -
76 // a rename() from there would no longer be atomic (or even possible)
77 if ($temporary !== false) {
78 @unlink($temporary);
79 }
80 throw new RuntimeException(sprintf('Could not create a temporary file next to "%s".', $file));
81 }
82
83 try {
84 $written_identity = self::writeContent($temporary, $content, $flush_to_disk);
85 // tempnam() creates the file with mode 0600: an existing target keeps its mode, a new
86 // one gets what a plain file_put_contents() would create
87 clearstatcache(true, $file);
88 $existing_mode = is_file($file) ? @fileperms($file) : false;
89 $mode = $existing_mode !== false ? $existing_mode & 07777 : 0666 & ~umask();
90 if (!@chmod($temporary, $mode)) {
91 self::acceptUnchangeableMode($temporary, $mode);
92 }
93 if (!@rename($temporary, $file)) {
94 throw new RuntimeException(sprintf('Could not replace "%s".', $file));
95 }
96 } catch (\Throwable $t) {
97 @unlink($temporary);
98 throw $t;
99 }
100
101 // a symbolic link swapped in between the check above and the rename() (see the class
102 // comment) - the written file must not stay where the link pointed to
103 if ($resolved_root !== null) {
104 clearstatcache(true);
105 if (!self::isAtOrBelow(realpath(dirname($file)), $resolved_root)) {
106 // only the file written here - a link swapped in after a correct rename() would
107 // otherwise let this unlink() remove a foreign file of the same name
108 $current = @lstat($file);
109 $removed = $current !== false
110 && $current['ino'] === $written_identity['ino']
111 && $current['dev'] === $written_identity['dev']
112 && @unlink($file);
113 throw new RuntimeException(sprintf(
114 '"%s" no longer resolves to a path below "%s" after writing it - %s.',
115 $file,
116 $confine_to_directory,
117 $removed ? 'the written file was removed' : 'nothing was removed'
118 ));
119 }
120 }
121 }
static acceptUnchangeableMode(string $file, int $mode)
Some file systems do not support chmod(): that is only an error if the resulting mode lacks a permiss...
static writeContent(string $file, string $content, bool $flush_to_disk)
Writes $content to $file and with $flush_to_disk flushes it, so the rename() cannot publish content t...

Referenced by ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testAnExistingTargetKeepsItsOwnModeInsteadOfANewFilesMode(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testConfineToDirectoryAllowsAWriteWhoseTargetResolvesInsideIt(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testConfineToDirectoryThrowsWhenTheTargetResolvesOutsideIt(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testFlushToDiskDisabledStillHonoursConfineToDirectory(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testReplacesAnExistingFile(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testTheWrittenFileDoesNotKeepTheRestrictiveTempnamPermissions(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testThrowsWhenAPathComponentIsARegularFile(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testThrowsWhenTheTargetCannotBeReplacedAndRemovesTheTemporaryFile(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testThrowsWhenTheTargetDirectoryDoesNotExist(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testWritesANewFileAndLeavesNoTemporaryFileBehind(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testWritesCompleteContentWithFlushToDiskDisabled(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testWritesEmptyContent(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testWritesIntoASymlinkedDirectory(), ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testWritesToARelativePath(), and ILIAS\Language\Tests\ComponentTranslation\AtomicFileWriterTest\testWritesWithADoubleSlashInThePath().

+ Here is the caller graph for this function:

◆ writeContent()

static ILIAS\Language\ComponentTranslation\AtomicFileWriter::writeContent ( string  $file,
string  $content,
bool  $flush_to_disk 
)
staticprivate

Writes $content to $file and with $flush_to_disk flushes it, so the rename() cannot publish content that is not persisted yet.

Returns
array{ino: int, dev: int} identity of the written file (fstat() of the handle used)

Definition at line 170 of file AtomicFileWriter.php.

170 : array
171 {
172 $handle = @fopen($file, 'wb');
173 if ($handle === false) {
174 throw new RuntimeException(sprintf('Could not write "%s".', $file));
175 }
176 try {
177 $written = 0;
178 $length = strlen($content);
179 while ($written < $length) {
180 $bytes = @fwrite($handle, substr($content, $written));
181 if ($bytes === false || $bytes === 0) {
182 throw new RuntimeException(sprintf('Could not write "%s".', $file));
183 }
184 $written += $bytes;
185 }
186 if (!fflush($handle) || ($flush_to_disk && !fsync($handle))) {
187 throw new RuntimeException(sprintf('Could not flush "%s" to disk.', $file));
188 }
189 $stat = fstat($handle);
190 if ($stat === false) {
191 throw new RuntimeException(sprintf('Could not stat "%s".', $file));
192 }
193
194 return ['ino' => $stat['ino'], 'dev' => $stat['dev']];
195 } finally {
196 fclose($handle);
197 }
198 }
if(! $all_ok) if(!is_dir($output_dir) &&! @mkdir($output_dir, 0775, true) &&!is_dir($output_dir)) $written

References $written.


The documentation for this class was generated from the following file: