PluginProbe
SQLite Database Integration / 3.1.0
SQLite Database Integration v3.1.0
3.1.0 3.0.2 3.0.1 trunk 2.1.13 2.1.14 2.1.15 2.1.16 2.2.0 2.2.1 2.2.10 2.2.11 2.2.12 2.2.13 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.2.2 2.2.20 2.2.21 2.2.22 2.2.23 All 33 releases
sqlite-database-integration / wp-includes / sqlite / class-wp-sqlite-storage.php

class-wp-sqlite-storage.php in SQLite Database Integration 3.1.0, at wp-includes/sqlite/class-wp-sqlite-storage.php

601 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Manages the storage for the WordPress SQLite database.
5 *
6 * The storage handles the path of the SQLite database:
7 * - An explicit database path is used as is.
8 * - A database with a secret path is stored in a randomized protected directory.
9 * - A database in a legacy path is migrated to a secret path.
10 *
11 * The storage implements a locking mechanism for initialization and maintenance.
12 * Locking uses a dedicated empty SQLite database and a maintenance file:
13 * 1. An exclusive lock is acquired on the locking database at ".ht.sqlite.lock".
14 * 2. A maintenance marker file is created at ".ht.sqlite.maintenance":
15 * - When requests see the file, they use the locking database.
16 * - Its absence provides a fast path for normal traffic.
17 * 3. When the lock is released, the maintenance file is automatically removed.
18 *
19 * Database locking uses a PDO SQLite connection:
20 * phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO
21 *
22 * Filesystem warnings are suppressed to avoid exposing database paths:
23 * phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
24 */
25 class WP_SQLite_Storage {
26 /**
27 * Filename of a script that returns the path to the database file.
28 */
29 private const DATABASE_PATH_FILENAME = 'db-path.php';
30
31 /**
32 * Filename of the database file.
33 */
34 private const DATABASE_FILENAME = '.ht.sqlite';
35
36 /**
37 * Filename of the SQLite locking database.
38 */
39 private const LOCK_FILENAME = '.ht.sqlite.lock';
40
41 /**
42 * Filename of the marker that makes requests check the storage lock.
43 */
44 private const MAINTENANCE_FILENAME = '.ht.sqlite.maintenance';
45
46 /**
47 * Number of bytes in a generated random token.
48 */
49 private const TOKEN_BYTE_LENGTH = 16;
50
51 /**
52 * Directory with the storage lock files and, for a secret path, the database.
53 * Null for an in-memory database.
54 *
55 * @var string|null
56 */
57 private $database_root;
58
59 /**
60 * Explicitly configured database path, if any.
61 *
62 * @var string|null
63 */
64 private $database_path;
65
66 /**
67 * Absolute path of the database path file.
68 *
69 * @var string|null
70 */
71 private $database_path_file;
72
73 /**
74 * Absolute path of the SQLite locking database.
75 *
76 * @var string|null
77 */
78 private $lock_path;
79
80 /**
81 * Absolute path of the storage maintenance marker.
82 *
83 * @var string|null
84 */
85 private $maintenance_path;
86
87 /**
88 * Connection holding the exclusive transaction on the SQLite locking database.
89 *
90 * @var PDO|null
91 */
92 private $storage_lock_connection;
93
94 /**
95 * Connection holding the exclusive transaction on the WordPress SQLite database.
96 *
97 * @var PDO|null
98 */
99 private $database_lock_connection;
100
101 /**
102 * Time to wait for existing database connections, in milliseconds.
103 *
104 * @var int
105 */
106 private $database_lock_timeout = 10000;
107
108 /**
109 * Create a storage manager for a database with a secret path.
110 *
111 * The database is stored in a randomized directory inside the root directory.
112 * Legacy databases in the root directory are migrated to it.
113 *
114 * @param string $root Root directory of the database storage.
115 * @return self The storage manager.
116 * @throws RuntimeException When the root directory is not an absolute path.
117 */
118 public static function with_secret_path( string $root ): self {
119 if ( ! self::is_absolute_path( $root ) ) {
120 throw new RuntimeException( 'The SQLite database directory must be an absolute filesystem path.' );
121 }
122
123 $storage = new self();
124 $storage->set_database_root( $root );
125 return $storage;
126 }
127
128 /**
129 * Create a storage manager for an explicit database path.
130 *
131 * @param string $path Absolute database path or ":memory:".
132 * @return self The storage manager.
133 * @throws RuntimeException When the database path is invalid.
134 */
135 public static function with_explicit_path( string $path ): self {
136 if ( ':memory:' !== $path ) {
137 if ( ! self::is_absolute_path( $path ) ) {
138 throw new RuntimeException( 'The SQLite database path must be an absolute filesystem path or ":memory:".' );
139 }
140 if ( is_dir( $path ) || in_array( substr( $path, -1 ), array( '/', DIRECTORY_SEPARATOR ), true ) ) {
141 throw new RuntimeException( 'The SQLite database path must point to a file, not a directory.' );
142 }
143 }
144
145 $storage = new self();
146 $storage->database_path = $path;
147 if ( ':memory:' !== $path ) {
148 $storage->set_database_root( dirname( $path ) );
149 }
150 return $storage;
151 }
152
153 /**
154 * Initialize the SQLite database storage.
155 *
156 * Uses an explicit file path or ":memory:" as configured. Otherwise, initializes
157 * a secret randomized path and migrates legacy databases as needed.
158 *
159 * @return string Absolute path to the SQLite database file, or ":memory:".
160 * @throws RuntimeException When the storage cannot be initialized.
161 */
162 public function initialize(): string {
163 // An in-memory database has no files.
164 if ( ':memory:' === $this->database_path ) {
165 return $this->database_path;
166 }
167
168 // Never resolve a database path while storage maintenance is in progress.
169 $this->wait_until_unlocked();
170
171 // Check if we're already holding a lock.
172 $was_locked = null !== $this->storage_lock_connection;
173
174 // Explicitly configured database path.
175 if ( null !== $this->database_path ) {
176 $this->ensure_database( $this->database_path );
177 if ( $was_locked ) {
178 // If the storage was locked, ensure the database is locked as well.
179 $this->lock();
180 }
181 return $this->database_path;
182 }
183
184 // Initialized secret database path.
185 $database_path = $this->read_recorded_database_path();
186 if ( null !== $database_path && @is_file( $database_path ) ) {
187 return $database_path;
188 }
189
190 // Initialize or repair the secret path under the storage lock.
191 // Preserve a lock already held by this instance for a larger operation.
192 $this->lock();
193 try {
194 // Another process may have completed the initialization meanwhile.
195 $database_path = $this->read_recorded_database_path();
196 if ( null !== $database_path && @is_file( $database_path ) ) {
197 return $database_path;
198 }
199
200 if ( null === $database_path ) {
201 $database_path = $this->publish_database_path();
202 }
203
204 // Migrate legacy database paths.
205 $legacy_path = $this->database_root . self::DATABASE_FILENAME;
206 if ( ! @is_file( $legacy_path ) ) {
207 $legacy_path = $this->database_root . self::DATABASE_FILENAME . '.php';
208 }
209 if ( @is_file( $legacy_path ) ) {
210 $this->move_legacy_database( $legacy_path, $database_path );
211 } else {
212 $this->ensure_database( $database_path );
213 // The earlier lock() call only locked the storage because the database
214 // did not exist yet. Lock the new database too, so callers that already
215 // held the storage lock retain both locks after initialization.
216 $this->lock();
217 }
218 return $database_path;
219 } finally {
220 if ( ! $was_locked ) {
221 $this->unlock();
222 }
223 }
224 }
225
226 /**
227 * Lock the database storage for maintenance.
228 *
229 * The locking mechanism does the following:
230 * - Waits for a lock held by another process, up to the database lock timeout.
231 * - Marks maintenance as active so new requests wait.
232 * - When a database exists, also acquires an exclusive database lock.
233 *
234 * Existing database connections must be closed first to avoid blocking the lock.
235 *
236 * The lock is released when unlock() is called or the process ends.
237 *
238 * @throws RuntimeException When the lock cannot be acquired.
239 */
240 public function lock(): void {
241 // An in-memory database has no files to lock.
242 if ( ':memory:' === $this->database_path ) {
243 return;
244 }
245
246 $was_locked = null !== $this->storage_lock_connection;
247
248 // Serialize maintenance through the dedicated locking database.
249 if ( ! $was_locked ) {
250 try {
251 $this->ensure_database( $this->lock_path );
252 $connection = new PDO( 'sqlite:' . $this->lock_path );
253 $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
254 $connection->exec( 'PRAGMA busy_timeout = ' . $this->database_lock_timeout );
255 $connection->exec( 'BEGIN EXCLUSIVE' );
256 $this->storage_lock_connection = $connection;
257 } catch ( Throwable $exception ) {
258 throw new RuntimeException( 'Failed to acquire the SQLite storage lock.', 0, $exception );
259 }
260 }
261
262 try {
263 // Ensure the maintenance marker exists so new requests use the locking database.
264 if ( ! @is_file( $this->maintenance_path ) ) {
265 if ( false === @file_put_contents( $this->maintenance_path, '' ) ) {
266 throw new RuntimeException( 'Failed to create the SQLite storage maintenance marker.' );
267 }
268 @chmod( $this->maintenance_path, 0600 );
269 }
270
271 // Reuse a database lock already held by this instance.
272 if ( null !== $this->database_lock_connection ) {
273 return;
274 }
275
276 // Look for an existing database so that it can be locked as well.
277 $database_path = $this->database_path;
278 if ( null === $database_path ) {
279 $database_path = $this->read_recorded_database_path();
280
281 // The database may still be at a legacy path.
282 if ( null === $database_path || ! @is_file( $database_path ) ) {
283 $database_path = $this->database_root . self::DATABASE_FILENAME;
284 }
285 if ( ! @is_file( $database_path ) ) {
286 $database_path = $this->database_root . self::DATABASE_FILENAME . '.php';
287 }
288 }
289
290 // Lock the existing database when found.
291 if ( @is_file( $database_path ) ) {
292 try {
293 $this->database_lock_connection = $this->lock_database( $database_path );
294 } catch ( Throwable $exception ) {
295 throw new RuntimeException( 'Failed to lock the SQLite database.', 0, $exception );
296 }
297 }
298 } catch ( Throwable $exception ) {
299 if ( ! $was_locked ) {
300 $this->unlock();
301 }
302 throw $exception;
303 }
304 }
305
306 /**
307 * Unlock the storage.
308 */
309 public function unlock(): void {
310 $this->database_lock_connection = null;
311 if ( null !== $this->storage_lock_connection ) {
312 // Remove the marker before releasing the lock that protects it.
313 @unlink( $this->maintenance_path );
314 $this->storage_lock_connection = null;
315 }
316 }
317
318 /**
319 * Create a storage manager. Use with_secret_path() or with_explicit_path().
320 */
321 private function __construct() {
322 }
323
324 /**
325 * Set the storage root directory and the paths derived from it.
326 *
327 * @param string $database_root Storage root directory.
328 */
329 private function set_database_root( string $database_root ): void {
330 $this->database_root = rtrim( $database_root, '/\\' ) . '/';
331 $this->database_path_file = $this->database_root . self::DATABASE_PATH_FILENAME;
332 $this->lock_path = $this->database_root . self::LOCK_FILENAME;
333 $this->maintenance_path = $this->database_root . self::MAINTENANCE_FILENAME;
334 }
335
336 /**
337 * Check whether a path is an absolute filesystem path.
338 *
339 * @param string $path Filesystem path.
340 * @return bool Whether the path is absolute.
341 */
342 private static function is_absolute_path( string $path ): bool {
343 if ( '' === $path || false !== strpos( $path, "\0" ) ) {
344 return false;
345 }
346
347 if ( '/' === DIRECTORY_SEPARATOR ) {
348 return '/' === $path[0];
349 } else {
350 // Match Windows absolute path with a drive letter or UNC share.
351 return 1 === preg_match(
352 '~^(?:[a-zA-Z]:/|//[^/]+/[^/]+/)~',
353 str_replace( '\\', '/', $path )
354 );
355 }
356 }
357
358 /**
359 * Wait until no maintenance process holds an active storage lock.
360 */
361 private function wait_until_unlocked(): void {
362 // A lock held by this instance must not block its own work.
363 if ( null !== $this->storage_lock_connection || ! @is_file( $this->maintenance_path ) ) {
364 return;
365 }
366
367 try {
368 $this->ensure_database( $this->lock_path );
369 $connection = new PDO( 'sqlite:' . $this->lock_path );
370 $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
371 $connection->exec( 'PRAGMA busy_timeout = ' . $this->database_lock_timeout );
372 $connection->exec( 'BEGIN EXCLUSIVE' );
373
374 // Clear a stale marker while the transaction prevents new maintenance from starting.
375 @unlink( $this->maintenance_path );
376 } catch ( Throwable $exception ) {
377 throw new RuntimeException( 'Failed to check the SQLite storage lock.', 0, $exception );
378 }
379 }
380
381 /**
382 * Reads the recorded database path.
383 *
384 * @return string|null Absolute database path, or null when none is recorded.
385 */
386 private function read_recorded_database_path(): ?string {
387 if ( ! @is_file( $this->database_path_file ) ) {
388 return null;
389 }
390
391 try {
392 // Use include so an unreadable file can be handled without terminating on PHP 7.
393 $database_path = @include $this->database_path_file;
394 } catch ( Throwable $exception ) {
395 throw new RuntimeException( 'Failed to read the SQLite database path file.', 0, $exception );
396 }
397
398 if ( false === $database_path ) {
399 throw new RuntimeException( 'Failed to read the SQLite database path file.' );
400 }
401
402 if ( ! is_string( $database_path ) || '' === $database_path ) {
403 throw new RuntimeException( 'The SQLite database path file is invalid.' );
404 }
405
406 return $database_path;
407 }
408
409 /**
410 * Generate a randomized database path and publish it atomically.
411 *
412 * @return string Absolute path to the published SQLite database file.
413 */
414 private function publish_database_path(): string {
415 $directory_name = '.ht.' . bin2hex( random_bytes( self::TOKEN_BYTE_LENGTH ) );
416 $temporary_path = $this->database_root . 'tmp.' . self::DATABASE_PATH_FILENAME;
417
418 $database_path_contents = sprintf(
419 "<?php\n\n/**\n * SQLite database path.\n *\n * IMPORTANT: Keep this path secret. When possible, point it outside the document root.\n */\nreturn __DIR__ . '/%s/%s';\n",
420 $directory_name,
421 self::DATABASE_FILENAME
422 );
423
424 // Publish the complete file atomically.
425 if ( false === @file_put_contents( $temporary_path, $database_path_contents ) ) {
426 throw new RuntimeException( 'Failed to write the SQLite database path file.' );
427 }
428 @chmod( $temporary_path, 0600 );
429
430 if ( ! @rename( $temporary_path, $this->database_path_file ) ) {
431 @unlink( $temporary_path );
432 throw new RuntimeException( 'Failed to publish the SQLite database path file.' );
433 }
434
435 // This runs before wp_opcache_invalidate() is available.
436 $opcache_restrict_api = ini_get( 'opcache.restrict_api' );
437 $script_filename = isset( $_SERVER['SCRIPT_FILENAME'] ) ? realpath( $_SERVER['SCRIPT_FILENAME'] ) : false;
438 if (
439 function_exists( 'opcache_invalidate' )
440 && ( ! $opcache_restrict_api || ( $script_filename && 0 === stripos( $script_filename, $opcache_restrict_api ) ) )
441 ) {
442 opcache_invalidate( $this->database_path_file, true );
443 }
444
445 return $this->database_root . $directory_name . '/' . self::DATABASE_FILENAME;
446 }
447
448 /**
449 * Move a legacy database into a secret path.
450 *
451 * @param string $legacy_path Absolute path of the legacy database file.
452 * @param string $database_path Absolute destination path.
453 */
454 private function move_legacy_database( string $legacy_path, string $database_path ): void {
455 $this->ensure_protected_directory( dirname( $database_path ) );
456
457 /*
458 * Close the connection just before moving the database. SQLite considers
459 * renaming an open database undefined, and Windows generally prevents it.
460 * We cannot fully prevent race conditions, but this makes them unlikely.
461 *
462 * See: https://www.sqlite.org/howtocorrupt.html#unlink
463 */
464 $this->database_lock_connection = null;
465 if ( ! @rename( $legacy_path, $database_path ) ) {
466 throw new RuntimeException( 'Failed to move the SQLite database file.' );
467 }
468 @chmod( $database_path, 0600 );
469
470 try {
471 $this->database_lock_connection = $this->lock_database( $database_path );
472 } catch ( Throwable $exception ) {
473 throw new RuntimeException( 'Failed to lock the migrated SQLite database.', 0, $exception );
474 }
475 }
476
477 /**
478 * Ensure that a database and its protected directory exist.
479 *
480 * @param string $database_path Absolute database path.
481 */
482 private function ensure_database( string $database_path ): void {
483 $this->ensure_protected_directory( dirname( $database_path ) );
484
485 if ( ! @is_file( $database_path ) ) {
486 // Create an empty database file with restricted permissions.
487 $database_handle = @fopen( $database_path, 'c' );
488 if ( false === $database_handle ) {
489 throw new RuntimeException( 'Failed to create the SQLite database file.' );
490 }
491 fclose( $database_handle );
492 @chmod( $database_path, 0600 );
493 }
494 }
495
496 /**
497 * Ensure that a database directory exists and deny direct access.
498 *
499 * @param string $directory Absolute directory path.
500 */
501 private function ensure_protected_directory( string $directory ): void {
502 if ( ! @is_dir( $directory ) ) {
503 // Create the path one directory at a time to avoid changing the process-wide umask.
504 $missing_directories = array();
505 for ( $path = rtrim( $directory, '/\\' ); ! @is_dir( $path ); $path = dirname( $path ) ) {
506 $missing_directories[] = $path;
507 if ( dirname( $path ) === $path ) {
508 break;
509 }
510 }
511
512 foreach ( array_reverse( $missing_directories ) as $path ) {
513 if ( ! @mkdir( $path, 0700 ) && ! @is_dir( $path ) ) {
514 throw new RuntimeException( 'Failed to create the SQLite database directory.' );
515 }
516 @chmod( $path, 0700 );
517 }
518 }
519
520 $this->ensure_file( rtrim( $directory, '/\\' ) . '/.htaccess', 'DENY FROM ALL' );
521 $this->ensure_file( rtrim( $directory, '/\\' ) . '/index.php', '<?php // Silence is golden.' );
522 }
523
524 /**
525 * Create a protected file when it does not already exist.
526 *
527 * @param string $path Absolute file path.
528 * @param string $contents File contents.
529 */
530 private function ensure_file( string $path, string $contents ): void {
531 if ( @is_file( $path ) ) {
532 return;
533 }
534 if ( false === @file_put_contents( $path, $contents ) ) {
535 throw new RuntimeException( 'Failed to create SQLite database protection file.' );
536 }
537 @chmod( $path, 0600 );
538 }
539
540 /**
541 * Acquire an exclusive transaction on an SQLite database.
542 *
543 * @param string $database_path Absolute database path.
544 * @return PDO Connection holding the transaction.
545 */
546 private function lock_database( string $database_path ): PDO {
547 // Do not silently create an empty database if the expected file is missing.
548 $pdo_options = array();
549 if ( defined( 'Pdo\Sqlite::ATTR_OPEN_FLAGS' ) ) {
550 $pdo_options[ Pdo\Sqlite::ATTR_OPEN_FLAGS ] = Pdo\Sqlite::OPEN_READWRITE;
551 } elseif ( defined( 'PDO::SQLITE_ATTR_OPEN_FLAGS' ) ) {
552 $pdo_options[ PDO::SQLITE_ATTR_OPEN_FLAGS ] = PDO::SQLITE_OPEN_READWRITE;
553 }
554
555 $connection = null;
556 $deadline = microtime( true ) + ( $this->database_lock_timeout / 1000 );
557 do {
558 if ( microtime( true ) >= $deadline ) {
559 throw new RuntimeException( 'Failed to acquire an exclusive SQLite database lock.' );
560 }
561
562 if ( null === $connection ) {
563 $connection = new PDO( 'sqlite:' . $database_path, null, null, $pdo_options );
564 $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
565 $connection->exec( 'PRAGMA busy_timeout = ' . $this->database_lock_timeout );
566 }
567
568 try {
569 // Disable WAL so we can acquire a truly exclusive READ/WRITE lock.
570 $journal_mode = $connection->query( 'PRAGMA journal_mode = DELETE' )->fetchColumn();
571 if ( 'delete' === strtolower( (string) $journal_mode ) ) {
572 // Acquire exclusive lock.
573 $connection->exec( 'BEGIN EXCLUSIVE' );
574
575 // Another connection may have restored WAL before the transaction started.
576 // If that's the case, release the lock and retry in the next iteration.
577 $journal_mode = $connection->query( 'PRAGMA journal_mode' )->fetchColumn();
578 if ( 'delete' !== strtolower( (string) $journal_mode ) ) {
579 $connection->exec( 'ROLLBACK' );
580 } else {
581 // Connection lock successfully acquired.
582 return $connection;
583 }
584 }
585 } catch ( PDOException $exception ) {
586 $error_info = $connection->errorInfo();
587 $sqlite_busy = 5;
588 $database_busy = isset( $error_info[1] ) && ( (int) $error_info[1] & 0xff ) === $sqlite_busy;
589 if ( ! $database_busy ) {
590 throw $exception;
591 }
592
593 // Close the connection so any raw transaction is rolled back before retrying.
594 $connection = null;
595 }
596
597 usleep( 100000 ); // 100 milliseconds.
598 } while ( true );
599 }
600 }
601