| @@ -140,12 +140,34 @@ | ||
| 140 | 140 | |
| 141 | 141 | try { |
| 142 | 142 | // Read current content |
| 143 | 143 | $original = $this->read_file(); |
| 144 | + | |
| 145 | + /* | |
| 146 | + * read_file() answers '' when there is no file and false when there | |
| 147 | + * is one PHP cannot read. Until 2.11.8 both became an empty string | |
| 148 | + * here, so a .htaccess that PHP could write but not read was replaced | |
| 149 | + * whole by the Vigilant block, and every other rule in it was lost. | |
| 150 | + */ | |
| 144 | 151 | if ( false === $original ) { |
| 145 | - $original = ''; | |
| 152 | + return new WP_Error( 'read_failed', __( '.htaccess could not be read, so it was left as it is.', 'vigilante' ) ); | |
| 146 | 153 | } |
| 147 | 154 | |
| 155 | + /* | |
| 156 | + * A block that has lost a marker would take the rest of the file with | |
| 157 | + * it. The other known blocks are asked too: validate_content() below | |
| 158 | + * refuses any result where one of them is unmatched, and saying why | |
| 159 | + * here keeps that refusal from reading as a write failure worth | |
| 160 | + * retrying every hour. | |
| 161 | + */ | |
| 162 | + $whole = $this->blocks_are_whole( $original, $marker_start, $marker_end ); | |
| 163 | + foreach ( $this->known_blocks as $known_start => $known_end ) { | |
| 164 | + $whole = $whole && $this->blocks_are_whole( $original, $known_start, $known_end ); | |
| 165 | + } | |
| 166 | + if ( ! $whole ) { | |
| 167 | + return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) ); | |
| 168 | + } | |
| 169 | + | |
| 148 | 170 | // Create backup before modification |
| 149 | 171 | if ( ! empty( $original ) ) { |
| 150 | 172 | $this->create_backup( $original ); |
| 151 | 173 | } |
| @@ -190,8 +212,15 @@ | ||
| 190 | 212 | |
| 191 | 213 | return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) ); |
| 192 | 214 | } |
| 193 | 215 | |
| 216 | + // Record the block as Vigilant's own. The integrity scan leaves | |
| 217 | + // out of its hash only the blocks whose fingerprint is recorded, | |
| 218 | + // so anything else carrying these markers is still checked. | |
| 219 | + if ( class_exists( 'Vigilante_File_Integrity' ) ) { | |
| 220 | + Vigilante_File_Integrity::remember_owned_block( '.htaccess', $marker_start, $block ); | |
| 221 | + } | |
| 222 | + | |
| 194 | 223 | return true; |
| 195 | 224 | } finally { |
| 196 | 225 | $this->release_lock(); |
| 197 | 226 | } |
| @@ -199,31 +228,18 @@ | ||
| 199 | 228 | |
| 200 | 229 | /** |
| 201 | 230 | * Take the write lock, or fail if another process holds it. |
| 202 | 231 | * |
| 203 | - * add_option() is the atomic part: option_name carries a unique index, so | |
| 204 | - * exactly one caller can create the row. A lock older than the timeout is | |
| 205 | - * treated as abandoned (a fatal between acquire and release) and taken over, | |
| 206 | - * otherwise a single crash would freeze every future write. | |
| 232 | + * Until 2.11.8 this relied on add_option() being atomic, and it is not: it | |
| 233 | + * runs INSERT ... ON DUPLICATE KEY UPDATE, so two writers arriving together | |
| 234 | + * both believed they held the lock. See Vigilante_Settings::acquire_option_lock(). | |
| 235 | + * A lock older than the timeout still counts as abandoned and is taken over. | |
| 207 | 236 | * |
| 208 | 237 | * @since 2.10.0 |
| 209 | 238 | * @return bool |
| 210 | 239 | */ |
| 211 | 240 | private function acquire_lock() { |
| 212 | - $now = time(); | |
| 213 | - $held = get_option( self::LOCK_OPTION ); | |
| 214 | - | |
| 215 | - if ( false !== $held && is_numeric( $held ) && ( $now - (int) $held ) < self::LOCK_TIMEOUT ) { | |
| 216 | - return false; | |
| 217 | - } | |
| 218 | - | |
| 219 | - if ( false !== $held ) { | |
| 220 | - // Abandoned lock: take it over. | |
| 221 | - update_option( self::LOCK_OPTION, $now, false ); | |
| 222 | - return true; | |
| 223 | - } | |
| 224 | - | |
| 225 | - return (bool) add_option( self::LOCK_OPTION, $now, '', false ); | |
| 241 | + return Vigilante_Settings::acquire_option_lock( self::LOCK_OPTION, self::LOCK_TIMEOUT ); | |
| 226 | 242 | } |
| 227 | 243 | |
| 228 | 244 | /** |
| 229 | 245 | * Release the write lock. |
| @@ -230,57 +246,101 @@ | ||
| 230 | 246 | * |
| 231 | 247 | * @since 2.10.0 |
| 232 | 248 | */ |
| 233 | 249 | private function release_lock() { |
| 234 | - delete_option( self::LOCK_OPTION ); | |
| 250 | + Vigilante_Settings::release_option_lock( self::LOCK_OPTION ); | |
| 235 | 251 | } |
| 236 | 252 | |
| 237 | 253 | /** |
| 254 | + * Drop the integrity scan's record of a block Vigilant no longer has in the file | |
| 255 | + * | |
| 256 | + * @since 2.11.5 | |
| 257 | + * | |
| 258 | + * @param string $marker_start Start marker of the block. | |
| 259 | + */ | |
| 260 | + private function forget_owned_block( $marker_start ) { | |
| 261 | + if ( class_exists( 'Vigilante_File_Integrity' ) ) { | |
| 262 | + Vigilante_File_Integrity::forget_owned_blocks( '.htaccess', $marker_start ); | |
| 263 | + } | |
| 264 | + } | |
| 265 | + | |
| 266 | + /** | |
| 238 | 267 | * Remove a block from .htaccess |
| 239 | 268 | * |
| 269 | + * Takes the same write lock as add_block(): until 2.11.0 this | |
| 270 | + * read-modify-write ran unlocked, so a removal racing an addition of a | |
| 271 | + * different block could drop the block that had just been written (S5). | |
| 272 | + * | |
| 240 | 273 | * @param string $marker_start Start marker. |
| 241 | 274 | * @param string $marker_end End marker. |
| 275 | + * @param bool $automatic True when Vigilant removes the block by itself | |
| 276 | + * (a mode expiring on cron), false when a person | |
| 277 | + * asked for it. Same distinction as add_block(). | |
| 242 | 278 | * @return bool|WP_Error |
| 243 | 279 | */ |
| 244 | - public function remove_block( $marker_start, $marker_end ) { | |
| 245 | - if ( ! Vigilante_Settings::can_write_shared_files() ) { | |
| 280 | + public function remove_block( $marker_start, $marker_end, $automatic = false ) { | |
| 281 | + $allowed = $automatic | |
| 282 | + ? Vigilante_Settings::owns_shared_files() | |
| 283 | + : Vigilante_Settings::can_write_shared_files(); | |
| 284 | + | |
| 285 | + if ( ! $allowed ) { | |
| 246 | 286 | return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() ); |
| 247 | 287 | } |
| 248 | 288 | |
| 249 | - // Read current content | |
| 250 | - $content = $this->read_file(); | |
| 251 | - | |
| 252 | - if ( false === $content || empty( $content ) ) { | |
| 253 | - return true; // Nothing to remove | |
| 289 | + if ( ! $this->acquire_lock() ) { | |
| 290 | + return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) ); | |
| 254 | 291 | } |
| 255 | 292 | |
| 256 | - // Check if block exists | |
| 257 | - if ( strpos( $content, $marker_start ) === false ) { | |
| 258 | - return true; // Block doesn't exist, nothing to do | |
| 259 | - } | |
| 293 | + try { | |
| 294 | + // Read current content | |
| 295 | + $content = $this->read_file(); | |
| 260 | 296 | |
| 261 | - // Create backup before modification | |
| 262 | - $this->create_backup( $content ); | |
| 297 | + if ( false === $content || empty( $content ) ) { | |
| 298 | + // An unreadable file proves nothing about the block, so its | |
| 299 | + // record stays. A missing or empty one has no block left. | |
| 300 | + if ( false !== $content ) { | |
| 301 | + $this->forget_owned_block( $marker_start ); | |
| 302 | + } | |
| 303 | + return true; // Nothing to remove | |
| 304 | + } | |
| 263 | 305 | |
| 264 | - // Remove the block | |
| 265 | - $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end ); | |
| 306 | + // Check if block exists | |
| 307 | + if ( strpos( $content, $marker_start ) === false ) { | |
| 308 | + $this->forget_owned_block( $marker_start ); | |
| 309 | + return true; // Block doesn't exist, nothing to do | |
| 310 | + } | |
| 266 | 311 | |
| 267 | - // Validate result - WordPress rules should still be there if they were before | |
| 268 | - if ( strpos( $content, '# BEGIN WordPress' ) !== false && | |
| 269 | - strpos( $new_content, '# BEGIN WordPress' ) === false ) { | |
| 270 | - // WordPress rules were removed - this is wrong, restore backup | |
| 312 | + // A block that has lost a marker would take the rest of the file with it. | |
| 313 | + if ( ! $this->blocks_are_whole( $content, $marker_start, $marker_end ) ) { | |
| 314 | + return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) ); | |
| 315 | + } | |
| 316 | + | |
| 317 | + // Create backup before modification | |
| 318 | + $this->create_backup( $content ); | |
| 319 | + | |
| 320 | + // Remove the block | |
| 321 | + $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end ); | |
| 322 | + | |
| 323 | + // Validate result - WordPress rules should still be there if they were before | |
| 324 | + if ( strpos( $content, '# BEGIN WordPress' ) !== false && | |
| 325 | + strpos( $new_content, '# BEGIN WordPress' ) === false ) { | |
| 326 | + // WordPress rules were removed - this is wrong, restore backup | |
| 327 | + $this->restore_backup(); | |
| 328 | + return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) ); | |
| 329 | + } | |
| 330 | + | |
| 331 | + // Write file | |
| 332 | + if ( $this->write_file( $new_content ) ) { | |
| 333 | + $this->forget_owned_block( $marker_start ); | |
| 334 | + return true; | |
| 335 | + } | |
| 336 | + | |
| 337 | + // Write failed, restore backup | |
| 271 | 338 | $this->restore_backup(); |
| 272 | - return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) ); | |
| 339 | + return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) ); | |
| 340 | + } finally { | |
| 341 | + $this->release_lock(); | |
| 273 | 342 | } |
| 274 | - | |
| 275 | - // Write file | |
| 276 | - if ( $this->write_file( $new_content ) ) { | |
| 277 | - return true; | |
| 278 | - } | |
| 279 | - | |
| 280 | - // Write failed, restore backup | |
| 281 | - $this->restore_backup(); | |
| 282 | - return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) ); | |
| 283 | 343 | } |
| 284 | 344 | |
| 285 | 345 | /** |
| 286 | 346 | * Check if a block exists in .htaccess |
| @@ -296,8 +356,47 @@ | ||
| 296 | 356 | return strpos( $content, $marker_start ) !== false; |
| 297 | 357 | } |
| 298 | 358 | |
| 299 | 359 | /** |
| 360 | + * Whether no start marker of a block is left without its end | |
| 361 | + * | |
| 362 | + * The removal below works line by line and keeps dropping lines from a start | |
| 363 | + * marker until it meets an end marker, so a start whose end is missing, or a | |
| 364 | + * second start before the end, takes everything after it. Neither existing | |
| 365 | + * check catches that: remove_block() only looks for "# BEGIN WordPress", | |
| 366 | + * which the rules Network Setup hands out do not carry, and the | |
| 367 | + * validate_content() of add_block() only compares marker pairs, which still | |
| 368 | + * match once both WordPress markers have been cut away. An end marker with | |
| 369 | + * no start before it is harmless to the removal, which just drops that | |
| 370 | + * line, so it does not count against the content. | |
| 371 | + * | |
| 372 | + * @since 2.11.6 | |
| 373 | + * | |
| 374 | + * @param string $content Content to check. | |
| 375 | + * @param string $marker_start Start marker. | |
| 376 | + * @param string $marker_end End marker. | |
| 377 | + * @return bool | |
| 378 | + */ | |
| 379 | + private function blocks_are_whole( $content, $marker_start, $marker_end ) { | |
| 380 | + $inside = false; | |
| 381 | + | |
| 382 | + foreach ( explode( "\n", $content ) as $line ) { | |
| 383 | + $line = trim( $line ); | |
| 384 | + | |
| 385 | + if ( $line === $marker_start ) { | |
| 386 | + if ( $inside ) { | |
| 387 | + return false; | |
| 388 | + } | |
| 389 | + $inside = true; | |
| 390 | + } elseif ( $line === $marker_end ) { | |
| 391 | + $inside = false; | |
| 392 | + } | |
| 393 | + } | |
| 394 | + | |
| 395 | + return ! $inside; | |
| 396 | + } | |
| 397 | + | |
| 398 | + /** | |
| 300 | 399 | * Remove a specific block from content string |
| 301 | 400 | * |
| 302 | 401 | * @param string $content Content to modify. |
| 303 | 402 | * @param string $marker_start Start marker. |
| @@ -497,10 +596,20 @@ | ||
| 497 | 596 | return false; |
| 498 | 597 | } |
| 499 | 598 | } |
| 500 | 599 | |
| 600 | + /* | |
| 601 | + * Keep the permissions the file already has. put_contents() always sets | |
| 602 | + * a mode, and FS_CHMOD_FILE is "permissions of index.php | 0644", so | |
| 603 | + * until 2.11.6 every write left a .htaccess kept at 0640 at 0644 or | |
| 604 | + * wider. A mode that cannot be read falls back to the old one rather | |
| 605 | + * than to 0, which would lock the server out of the file. | |
| 606 | + */ | |
| 607 | + $perms = file_exists( $this->htaccess_path ) ? fileperms( $this->htaccess_path ) : false; | |
| 608 | + $mode = ( false !== $perms && ( $perms & 0777 ) ) ? ( $perms & 0777 ) : FS_CHMOD_FILE; | |
| 609 | + | |
| 501 | 610 | // Write with WP_Filesystem |
| 502 | - return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE ); | |
| 611 | + return $wp_filesystem->put_contents( $this->htaccess_path, $content, $mode ); | |
| 503 | 612 | } |
| 504 | 613 | |
| 505 | 614 | /** |
| 506 | 615 | * Create backup of current .htaccess |