.parts/ a big file still arriving: each range gzipped on its own * * Received bytes are never written to disk as they arrived: a small file goes straight to its object * (gzipped if it is a script), and a big one arrives as gzipped ranges that are assembled into its * object when the last one is in (put_range(), put_parts()). Large database values the same way. * * The file's real path, size, modification time and checksum are in index/files.jsonl ("gz": 1 * marks a compressed object; cloud sets use "z" for their zip entries), and restores put * each file back under its own name. So a set never holds a file the web server could run or * serve as what it was: nothing is named .php, and script files (PHP, .htaccess, CGI and shell * scripts) aren't even stored as source. Identical files share one object. * * Format 1 (1.0.2 and earlier, still restorable): a mirrored tree, files//, with * index rows that may carry "lp" (the stored name, when it had to differ). * * @package AccessNow\Replica */ namespace AccessNow\Replica\Sets; use AccessNow\Replica\Storage; use AccessNow\Replica\Util\Fs; defined( 'ABSPATH' ) || exit; // Exception messages are plain text: they are escaped where shown (esc_html() in PHP, textContent in admin.js). // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped /** * File storage inside a set. */ final class Set_Files { /** * The format new sets are written in. */ const FORMAT = 2; /** * Extensions (and whole names) stored compressed: anything a web server might execute. */ const SCRIPT_EXTENSIONS = array( 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phps', 'phar', 'inc', 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp', 'aspx', 'jsp', 'shtml', 'htaccess' ); /** * Whole file names stored compressed. */ const SCRIPT_NAMES = array( '.htaccess', '.user.ini', 'php.ini', '.htpasswd' ); /** * The format of a set, from its manifest (complete or in progress). Sets without one are 1. * * @param string $dir Set dir (trailing slash). */ public static function format( $dir ) { static $cache = array(); if ( ! isset( $cache[ $dir ] ) ) { $m = Storage::read_json( $dir . 'manifest.json' ); if ( ! is_array( $m ) ) { $m = Storage::read_json( $dir . 'manifest.partial.json' ); } $version = is_array( $m ) ? (int) ( $m['format_version'] ?? 1 ) : 1; if ( ! is_array( $m ) ) { return $version; // Not written yet: don't cache a guess. } $cache[ $dir ] = $version; } return $cache[ $dir ]; } /** * The row's relative path, decoded, or null when it is not a plain relative path. * * @param array $row Index or scan row. */ public static function relative( array $row ) { $rel = isset( $row['p64'] ) ? (string) base64_decode( (string) $row['p64'], true ) : (string) ( $row['p'] ?? '' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- File names that are not valid UTF-8. return Fs::clean_relative( $rel ); } /** * Whether a file is stored compressed (a script, by its name). * * @param array $row Index or scan row. */ public static function is_script( array $row ) { $rel = (string) self::relative( $row ); $name = strtolower( basename( $rel ) ); if ( in_array( $name, self::SCRIPT_NAMES, true ) ) { return true; } // Every extension counts, not just the last: Apache runs photo.php.jpg as PHP under AddHandler. $parts = explode( '.', $name ); array_shift( $parts ); return (bool) array_intersect( $parts, self::SCRIPT_EXTENSIONS ); } /** * Path of an object. * * @param string $dir Set dir. * @param string $hash sha256 (hex). * @param bool $compressed Script file. * @throws \RuntimeException On a malformed hash. */ public static function object_path( $dir, $hash, $compressed ) { $hash = strtolower( (string) $hash ); if ( ! preg_match( '/^[0-9a-f]{64}$/', $hash ) ) { throw new \RuntimeException( 'A file in the backup has no valid checksum.' ); } return $dir . 'objects/' . substr( $hash, 0, 2 ) . '/' . $hash . ( $compressed ? '.gz' : '' ); } /** * Where a big file that is still arriving keeps its ranges. * * @param string $dir Set dir. * @param array $row Scan row. */ public static function parts_dir( $dir, array $row ) { $key = $row['r'] . '/' . ( isset( $row['p64'] ) ? 'b64:' . $row['p64'] : (string) ( $row['p'] ?? '' ) ); return $dir . 'tmp/' . md5( $key ) . '.parts/'; } /** * The file one range is kept in (gzipped), for writers that stream into it themselves. * * @param string $parts Parts folder. * @param int $offset Offset of the range in the file. */ public static function range_file( $parts, $offset ) { return $parts . sprintf( '%016d', (int) $offset ) . '.gz'; } /** * Keeps one range of a file that is still arriving, gzipped. A range sent again replaces itself. * * @param string $parts Parts folder. * @param int $offset Offset of the range in the file. * @param string $data Its bytes. * @throws \RuntimeException On write failure. */ public static function put_range( $parts, $offset, $data ) { self::ensure_dir( $parts ); $gz = gzencode( (string) $data, 1 ); if ( false === $gz ) { throw new \RuntimeException( 'Could not compress part of a file for the backup.' ); } self::write_object( self::range_file( $parts, $offset ), $gz ); } /** * Removes the ranges of a file (a fresh start, a skipped file, or once it is stored). * * @param string $parts Parts folder. */ public static function drop_parts( $parts ) { foreach ( (array) glob( $parts . '*' ) as $file ) { @unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink } @rmdir( $parts ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } /** * Joins a file's ranges, in order, into $out (gzipped when $compress). Returns the sha256 of the * first $size bytes, or false when a range is missing or unreadable. The ranges are kept. * * @param string $parts Parts folder. * @param int $size File size. * @param string $out Output path. * @param bool $compress Write it gzipped. * @return string|false */ public static function assemble( $parts, $size, $out, $compress ) { $files = (array) glob( $parts . '*.gz' ); sort( $files, SORT_STRING ); $fh = @fopen( ( $compress ? 'compress.zlib://' : '' ) . $out, 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $fh ) { return false; } $sha = hash_init( 'sha256' ); $pos = 0; $ok = true; foreach ( $files as $file ) { if ( $pos >= (int) $size ) { break; } $at = (int) basename( $file, '.gz' ); $data = gzdecode( (string) file_get_contents( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $data || $at > $pos ) { $ok = false; // Damaged, or a gap: a range never arrived. break; } $data = substr( $data, $pos - $at, (int) $size - $pos ); // Drop any overlap with what is written, and anything past the end. if ( '' === $data ) { continue; } fwrite( $fh, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite hash_update( $sha, $data ); $pos += strlen( $data ); } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( ! $ok || $pos !== (int) $size ) { @unlink( $out ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink return false; } return hash_final( $sha ); } /** * Stores a big file from its ranges, then removes them. Returns [sha256, index fields as * put_bytes()], or null when a range is missing. * * @param string $dir Set dir. * @param array $row Scan row. * @param string $parts Parts folder. * @param int $size File size. * @throws \RuntimeException On write failure. */ public static function put_parts( $dir, array $row, $parts, $size ) { $z = self::is_script( $row ); $tmp = $dir . 'objects/assembling-' . md5( $parts ) . '.tmp'; self::ensure_dir( dirname( $tmp ) ); $hash = self::assemble( $parts, $size, $tmp, $z ); if ( false === $hash ) { return null; } $object = self::object_path( $dir, $hash, $z ); if ( is_file( $object ) ) { @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Identical content is already stored. } else { self::ensure_dir( dirname( $object ) ); if ( ! @rename( $tmp, $object ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink throw new \RuntimeException( __( 'Could not write to the backup folder. Is the disk full?', 'accessnow-site-replica' ) ); } } self::drop_parts( $parts ); return array( $hash, $z ? array( 'gz' => 1 ) : array() ); } /** * Stores a small file's bytes. Returns the index fields that locate it: ['gz' => 1] or []. * * @param string $dir Set dir. * @param array $row Scan row. * @param string $bytes Bytes (already checked against $hash). * @param string $hash sha256. * @throws \RuntimeException On write failure. */ public static function put_bytes( $dir, array $row, $bytes, $hash ) { $z = self::is_script( $row ); $object = self::object_path( $dir, $hash, $z ); if ( ! is_file( $object ) ) { $data = $z ? gzencode( (string) $bytes, 6 ) : (string) $bytes; if ( false === $data ) { throw new \RuntimeException( 'Could not compress a file for the backup.' ); } self::write_object( $object, $data ); } return $z ? array( 'gz' => 1 ) : array(); } /** * Where a stored file's bytes are: [path, compressed]. Works for both formats. * * @param string $dir Set dir. * @param array $row Index row (t = f). * @throws \RuntimeException On a path outside the set. */ public static function source( $dir, array $row ) { if ( self::format( $dir ) >= 2 ) { return array( self::object_path( $dir, (string) ( $row['h'] ?? '' ), ! empty( $row['gz'] ) ), ! empty( $row['gz'] ) ); } $roots = \AccessNow\Replica\Files\Roots::NAMES; if ( ! in_array( (string) $row['r'], $roots, true ) ) { throw new \RuntimeException( 'A file path in the backup is not allowed.' ); } $stored = isset( $row['lp'] ) ? Fs::clean_relative( (string) $row['lp'] ) : self::relative( $row ); if ( null === $stored ) { throw new \RuntimeException( 'A file path in the backup is not allowed.' ); } return array( $dir . 'files/' . $row['r'] . '/' . $stored, false ); } /** * Whether a stored file is present (and, when not compressed, the right size). * * @param string $dir Set dir. * @param array $row Index row. */ public static function present( $dir, array $row ) { try { list( $path, $z ) = self::source( $dir, $row ); } catch ( \RuntimeException $e ) { return false; } clearstatcache( true, $path ); return is_file( $path ) && ( $z || (int) filesize( $path ) === (int) $row['s'] ); } /** * Writes a stored file out to $dest, uncompressed. * * @param string $dir Set dir. * @param array $row Index row. * @param string $dest Destination path. * @return bool */ public static function copy_out( $dir, array $row, $dest ) { list( $path, $z ) = self::source( $dir, $row ); return @copy( ( $z ? 'compress.zlib://' : '' ) . $path, $dest ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } /** * The stored bytes, uncompressed (for small files). * * @param string $dir Set dir. * @param array $row Index row. * @return string|false */ public static function read( $dir, array $row ) { list( $path, $z ) = self::source( $dir, $row ); $raw = @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $raw ) { return false; } return $z ? gzdecode( $raw ) : $raw; } /** * Carries an unchanged file over from the base set: a hardlink to the same object when the * base is format 2, or (from a format 1 base) a link, or a compressed copy for scripts. * Returns the index fields, as put_bytes(), or null when it couldn't. * * @param string $base_dir Base set dir. * @param array $base_row The base set's index row. * @param string $dir This set's dir. * @param array $row Scan row. */ public static function carry( $base_dir, array $base_row, $dir, array $row ) { $hash = (string) ( $base_row['h'] ?? '' ); if ( ! preg_match( '/^[0-9a-f]{64}$/', $hash ) ) { return null; } $z = self::is_script( $row ); $object = self::object_path( $dir, $hash, $z ); if ( is_file( $object ) ) { return $z ? array( 'gz' => 1 ) : array(); } try { list( $source, $source_z ) = self::source( $base_dir, $base_row ); } catch ( \RuntimeException $e ) { return null; } if ( ! is_file( $source ) ) { return null; } self::ensure_dir( dirname( $object ) ); if ( $source_z === $z ) { if ( ! @link( $source, $object ) && ! @copy( $source, $object ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return null; } } else { // A format 1 base stored scripts as they were: compress on the way in. $tmp = $object . '.tmp'; $in = ( $source_z ? 'compress.zlib://' : '' ) . $source; $out = ( $z ? 'compress.zlib://' : '' ) . $tmp; if ( ! @copy( $in, $out ) || ! @rename( $tmp, $object ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink return null; } } return $z ? array( 'gz' => 1 ) : array(); } /** * A large database value's bytes (db/big/…). Format 2 keeps them gzipped (".gz"), so no raw * value that happens to be code sits in the set as it was. * * @param string $dir Set dir. * @param string $file Path under the set, from index/bigvalues.jsonl. * @return string|false */ public static function read_value( $dir, $file ) { $file = (string) $file; if ( 0 !== strpos( $file, 'db/big/' ) || null === Fs::clean_relative( $file ) ) { return false; } $raw = @file_get_contents( $dir . $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( false === $raw ) { return false; } return '.gz' === substr( $file, -3 ) ? gzdecode( $raw ) : $raw; } /** * Writes an object atomically. * * @param string $object Path. * @param string $data Bytes as stored. * @throws \RuntimeException On write failure. */ private static function write_object( $object, $data ) { self::ensure_dir( dirname( $object ) ); $tmp = $object . '.tmp'; if ( false === file_put_contents( $tmp, $data ) || ! rename( $tmp, $object ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.WP.AlternativeFunctions.rename_rename @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink throw new \RuntimeException( __( 'Could not write to the backup folder. Is the disk full?', 'accessnow-site-replica' ) ); } } /** * Creates a folder inside the set. * * @param string $path Folder. * @throws \RuntimeException When it can't. */ private static function ensure_dir( $path ) { if ( ! is_dir( $path ) && ! wp_mkdir_p( $path ) ) { throw new \RuntimeException( sprintf( /* translators: %s: folder. */ __( 'Could not create folder %s.', 'accessnow-site-replica' ), $path ) ); } } }