Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion src/php/Flat_Files/Handlers/Functions_Snippet_Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
*/
class Functions_Snippet_Handler implements Snippet_Type_Handler {

/**
* The guard that prevents a flat file from being requested directly.
*/
private const DIRECT_ACCESS_GUARD = "if ( ! defined( 'ABSPATH' ) ) { return; }";

/**
* Set 'php' as the file extension for functions snippets, so they can be directly loaded.
*
Expand All @@ -30,11 +35,111 @@ public function get_dir_name(): string {
/**
* Wrap functions snippets by adding a header that disallows direct access.
*
* The guard cannot simply be prepended. PHP requires `declare` and
* `namespace` to come before any other statement, so a snippet opening with
* either used to fatal on load with "Namespace declaration statement has to
* be the very first statement" — taking down every page of the site, with
* nothing written to the error log. The guard is inserted after that
* prologue instead, and inside the braces when a namespace uses block
* syntax, since no code may sit outside `namespace {}` blocks.
*
* @param string $code Snippet PHP code.
*
* @return string Content snippet code with header prepended.
*/
public function wrap_code( string $code ): string {
return "<?php\n\nif ( ! defined( 'ABSPATH' ) ) { return; }\n\n" . $code;
$offset = $this->find_prologue_end( $code );

if ( 0 === $offset ) {
return "<?php\n\n" . self::DIRECT_ACCESS_GUARD . "\n\n" . $code;
}

return "<?php\n\n" . rtrim( substr( $code, 0, $offset ) ) .
"\n\n" . self::DIRECT_ACCESS_GUARD . "\n\n" .
ltrim( substr( $code, $offset ), "\n" );
}

/**
* Find the offset in the snippet code at which the guard may be inserted.
*
* Everything up to that offset is the statement prologue that PHP insists
* on seeing first: any number of `declare` statements, optionally followed
* by a namespace declaration. A braced namespace reports the offset just
* inside the opening brace rather than after the statement.
*
* @param string $code Snippet PHP code, stored without an opening tag.
*
* @return int Offset into `$code`, or zero to insert at the top.
*/
private function find_prologue_end( string $code ): int {
$open_tag = "<?php\n";
$tokens = token_get_all( $open_tag . $code );
$skipped = [ T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ];
$offset = 0;
$end = 0;
$in_statement = false;

foreach ( $tokens as $index => $token ) {
$offset += strlen( is_array( $token ) ? $token[1] : $token );

if ( $in_statement ) {
if ( '{' === $token ) {
// Block syntax: the guard belongs inside the braces.
$end = $offset;
break;
}

if ( ';' === $token ) {
$end = $offset;
$in_statement = false;
}

continue;
}

$id = is_array( $token ) ? $token[0] : null;

if ( in_array( $id, $skipped, true ) ) {
continue;
}

if ( T_DECLARE === $id || $this->is_namespace_declaration( $tokens, $index ) ) {
$in_statement = true;
continue;
}

break;
}

return max( 0, $end - strlen( $open_tag ) );
}

/**
* Check whether a token opens a namespace declaration.
*
* `namespace\my_function()` uses the same keyword as an operator, and must
* not be mistaken for a declaration.
*
* @param array<int, array{0: int, 1: string}|string> $tokens Token list.
* @param int $index Token to test.
*
* @return bool
*/
private function is_namespace_declaration( array $tokens, int $index ): bool {
if ( ! is_array( $tokens[ $index ] ) || T_NAMESPACE !== $tokens[ $index ][0] ) {
return false;
}

$count = count( $tokens );

for ( $next = $index + 1; $next < $count; $next++ ) {
if ( is_array( $tokens[ $next ] ) && T_WHITESPACE === $tokens[ $next ][0] ) {
continue;
}

return ! is_array( $tokens[ $next ] ) || T_NS_SEPARATOR !== $tokens[ $next ][0];
}

return false;
}
}
203 changes: 203 additions & 0 deletions tests/unit/Flat_Files/Handlers/Functions_Snippet_Handler_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

namespace Code_Snippets\Flat_Files\Handlers;

use Code_Snippets\UnitTestCase;

/**
* Tests for wrapping functions snippets in a direct-access guard.
*
* @group flat-files
*/
class Functions_Snippet_Handler_Test extends UnitTestCase {

/**
* Handler under test.
*
* @var Functions_Snippet_Handler
*/
private $handler;

/**
* Set up before each test.
*
* @return void
*/
public function set_up() {
parent::set_up();
$this->handler = new Functions_Snippet_Handler();
}

/**
* Assert that wrapped code is valid PHP.
*
* A namespace placed after another statement is a compile error, not a
* parse error, so neither token_get_all() nor include can be used to detect
* it from inside the test process: including the file kills the process
* outright. The check is delegated to a subprocess instead.
*
* @param string $wrapped Wrapped snippet code.
*
* @return void
*/
private function assert_valid_php( string $wrapped ): void {
if ( ! function_exists( 'exec' ) ) {
$this->markTestSkipped( 'exec() is unavailable, cannot lint generated code.' );
}

$file = tempnam( sys_get_temp_dir(), 'cs-flat-file-' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Linting a generated file requires a real file on disk.
file_put_contents( $file, $wrapped );

$output = [];
$status = 0;
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- A subprocess is the only way to detect a compile error without killing the test run.
exec( escapeshellcmd( PHP_BINARY ) . ' -l ' . escapeshellarg( $file ) . ' 2>&1', $output, $status );
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removing a temp file created by this test.
unlink( $file );

$this->assertSame(
0,
$status,
"Generated flat file is not valid PHP:\n" . implode( "\n", $output ) . "\n\n--- generated ---\n" . $wrapped
);
}

/**
* Snippets with no prologue keep the guard directly below the opening tag.
*
* @return void
*/
public function test_guard_is_added_at_the_top_of_an_ordinary_snippet(): void {
$wrapped = $this->handler->wrap_code( "function my_snippet() {}\n" );

$this->assertStringStartsWith( "<?php\n\nif ( ! defined( 'ABSPATH' ) ) { return; }", $wrapped );
$this->assertStringContainsString( 'function my_snippet() {}', $wrapped );
$this->assert_valid_php( $wrapped );
}

/**
* A namespaced snippet keeps its declaration first.
*
* Prepending the guard used to push the namespace declaration down the
* file, which is a fatal error and left the whole site unable to load.
*
* @return void
*/
public function test_guard_is_added_below_a_namespace_declaration(): void {
$wrapped = $this->handler->wrap_code( "namespace My\\Plugin;\n\nfunction my_snippet() {}\n" );

$this->assertLessThan(
strpos( $wrapped, "if ( ! defined( 'ABSPATH' ) )" ),
strpos( $wrapped, 'namespace My\\Plugin;' ),
'The namespace declaration must still come before the guard.'
);
$this->assert_valid_php( $wrapped );
}

/**
* A strict_types declaration must stay the very first statement.
*
* @return void
*/
public function test_guard_is_added_below_a_declare_statement(): void {
$wrapped = $this->handler->wrap_code( "declare(strict_types=1);\n\nfunction my_snippet() {}\n" );

$this->assertLessThan(
strpos( $wrapped, "if ( ! defined( 'ABSPATH' ) )" ),
strpos( $wrapped, 'declare(strict_types=1);' ),
'The declare statement must still come before the guard.'
);
$this->assert_valid_php( $wrapped );
}

/**
* A declare statement followed by a namespace is handled as one prologue.
*
* @return void
*/
public function test_guard_is_added_below_both_declare_and_namespace(): void {
$wrapped = $this->handler->wrap_code(
"declare(strict_types=1);\n\nnamespace My\\Plugin;\n\nfunction my_snippet() {}\n"
);

$this->assertLessThan(
strpos( $wrapped, "if ( ! defined( 'ABSPATH' ) )" ),
strpos( $wrapped, 'namespace My\\Plugin;' ),
'The namespace declaration must still come before the guard.'
);
$this->assert_valid_php( $wrapped );
}

/**
* Braced namespaces take the guard inside the block.
*
* No code may exist outside `namespace {}` blocks, so the guard cannot be
* placed above or below the declaration.
*
* @return void
*/
public function test_guard_is_added_inside_a_braced_namespace(): void {
$wrapped = $this->handler->wrap_code( "namespace My\\Plugin {\n\tfunction my_snippet() {}\n}\n" );

$this->assertLessThan(
strpos( $wrapped, "if ( ! defined( 'ABSPATH' ) )" ),
strpos( $wrapped, 'namespace My\\Plugin {' ),
'The guard must sit inside the namespace block.'
);
$this->assert_valid_php( $wrapped );
}

/**
* Leading comments do not hide the namespace declaration.
*
* @return void
*/
public function test_guard_is_added_below_a_namespace_preceded_by_comments(): void {
$wrapped = $this->handler->wrap_code(
"/**\n * Doc comment.\n */\n\n// A line comment.\nnamespace My\\Plugin;\n\nfunction my_snippet() {}\n"
);

$this->assertLessThan(
strpos( $wrapped, "if ( ! defined( 'ABSPATH' ) )" ),
strpos( $wrapped, 'namespace My\\Plugin;' ),
'The namespace declaration must still come before the guard.'
);
$this->assert_valid_php( $wrapped );
}

/**
* The namespace operator is not mistaken for a declaration.
*
* @return void
*/
public function test_namespace_operator_is_not_treated_as_a_declaration(): void {
$wrapped = $this->handler->wrap_code( "namespace\\my_function();\n" );

$this->assertStringStartsWith( "<?php\n\nif ( ! defined( 'ABSPATH' ) ) { return; }", $wrapped );
$this->assert_valid_php( $wrapped );
}

/**
* The guard is still present for every supported prologue.
*
* @return void
*/
public function test_every_snippet_shape_keeps_the_direct_access_guard(): void {
$shapes = [
'plain' => "function my_snippet() {}\n",
'namespace' => "namespace My\\Plugin;\n\nfunction my_snippet() {}\n",
'declare' => "declare(strict_types=1);\n\nfunction my_snippet() {}\n",
'both' => "declare(strict_types=1);\n\nnamespace My\\Plugin;\n\nfunction my_snippet() {}\n",
'braced' => "namespace My\\Plugin {\n\tfunction my_snippet() {}\n}\n",
];

foreach ( $shapes as $label => $code ) {
$this->assertStringContainsString(
"if ( ! defined( 'ABSPATH' ) ) { return; }",
$this->handler->wrap_code( $code ),
"The $label snippet lost its direct-access guard."
);
}
}
}
Loading