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
16 changes: 1 addition & 15 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,7 @@ jobs:
run: bash bin/build-plugins.sh

- name: Inspect Push MD plugin zip
shell: bash
run: |
set -euo pipefail

test -f dist/plugins/push-md.zip
zipinfo -1 dist/plugins/push-md.zip | tee /tmp/push-md-zip-contents.txt

grep -Fxq 'push-md/push-md.php' /tmp/push-md-zip-contents.txt
grep -Fxq 'push-md/readme.txt' /tmp/push-md-zip-contents.txt
grep -Fxq 'push-md/php-toolkit/vendor/composer/ClassLoader.php' /tmp/push-md-zip-contents.txt

! grep -Eq '^push-md/(Tests|docker-demo|docs)/' /tmp/push-md-zip-contents.txt
! grep -Fxq 'push-md/blueprint-e2e.json' /tmp/push-md-zip-contents.txt
! grep -Fxq 'push-md/push-md-dev-bootstrap.php' /tmp/push-md-zip-contents.txt
! grep -Fxq 'push-md/push-md-phar-bootstrap.php' /tmp/push-md-zip-contents.txt
run: bin/inspect-push-md-zip.sh dist/plugins/push-md.zip

- name: Upload Push MD release asset
env:
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/push-md-plugin-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Push MD Plugin Check

on:
push:
branches: [ trunk ]
pull_request:

jobs:
plugin-check:
name: Push MD Plugin Check
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, json
coverage: none
tools: composer:v2

- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress --optimize-autoloader

- name: Build Push MD plugin zip
run: bash bin/build-plugins.sh

- name: Unpack Push MD plugin zip
run: |
rm -rf .plugin-check-build
mkdir .plugin-check-build
unzip -q dist/plugins/push-md.zip -d .plugin-check-build

- name: Run Plugin Check
uses: wordpress/plugin-check-action@v1
with:
build-dir: './.plugin-check-build/push-md'
slug: 'push-md'
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
vendor/
**/*/vendor/
.DS_Store
**/.DS_Store
opfs/
node_modules
.cursor
Expand Down
110 changes: 110 additions & 0 deletions bin/build-plugins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ copy_pmd_toolkit_path() {
if [ -d "$source_path" ]; then
mkdir -p "$target_path"
rsync -a \
--exclude='.DS_Store' \
--include='LICENSE.md' \
--include='license.md' \
--exclude='*.dist' \
--exclude='*.json' \
--exclude='*.lock' \
Expand All @@ -47,9 +50,34 @@ copy_pmd_toolkit_path() {
--exclude='tests/' \
--exclude='Tests/' \
--exclude='Test/' \
--exclude='class-markdownimporter.php' \
--exclude='vendor-bin/' \
--exclude='vendor-patched/autoload.php' \
--exclude='vendor-patched/bin/' \
--exclude='vendor-patched/composer/' \
--exclude='vendor-patched/webuni/' \
--exclude='vendor-patched/symfony/yaml/' \
--exclude='vendor-patched/league/commonmark/src/Extension/Attributes/' \
--exclude='vendor-patched/league/commonmark/src/Extension/DefaultAttributes/' \
--exclude='vendor-patched/league/commonmark/src/Extension/DescriptionList/' \
--exclude='vendor-patched/league/commonmark/src/Extension/Embed/' \
--exclude='vendor-patched/league/commonmark/src/Extension/Footnote/' \
--exclude='vendor-patched/league/commonmark/src/Extension/FrontMatter/' \
--exclude='vendor-patched/league/commonmark/src/Extension/HeadingPermalink/' \
--exclude='vendor-patched/league/commonmark/src/Extension/Mention/' \
--exclude='vendor-patched/league/commonmark/src/Extension/SmartPunct/' \
--exclude='vendor-patched/league/commonmark/src/Extension/TableOfContents/' \
--exclude='vendor-patched/nette/utils/src/HtmlStringable.php' \
--exclude='vendor-patched/nette/utils/src/SmartObject.php' \
--exclude='vendor-patched/nette/utils/src/Translator.php' \
--exclude='vendor-patched/nette/utils/src/Utils/FileSystem.php' \
--exclude='vendor-patched/nette/utils/src/Utils/Finder.php' \
--exclude='vendor-patched/nette/utils/src/Utils/Html.php' \
--exclude='vendor-patched/nette/utils/src/Utils/Image*.php' \
--exclude='vendor-patched/nette/utils/src/Utils/Json.php' \
--exclude='vendor-patched/nette/utils/src/Utils/Paginator.php' \
--exclude='vendor-patched/nette/utils/src/Utils/Random.php' \
--exclude='vendor-patched/nette/utils/src/Iterators/' \
"$source_path/" \
"$target_path/"
elif [ -f "$source_path" ]; then
Expand Down Expand Up @@ -210,6 +238,85 @@ pmd_toolkit_write_files( $target_dir, $files );
PHP
}

harden_pmd_bundled_php() {
local target_dir=$1

PMD_BUNDLE_TARGET="$target_dir" php <<'PHP'
<?php

$target_dir = rtrim( getenv( 'PMD_BUNDLE_TARGET' ), '/' );

$phpcs_disable = '// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound,WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound,WordPress.Security.EscapeOutput.ExceptionNotEscaped,WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace,WordPress.PHP.DevelopmentFunctions.error_log_print_r,WordPress.PHP.DevelopmentFunctions.error_log_var_export,WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler,WordPress.WP.AlternativeFunctions,PluginCheck.CodeAnalysis.Heredoc.NotAllowed' . "\n";
$guard = "if ( ! defined( 'ABSPATH' ) ) {\n\texit;\n}\n\n";

function push_md_add_phpcs_disable( $code, $phpcs_disable ) {
if ( false !== strpos( $code, 'phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound' ) ) {
return $code;
}

return preg_replace( '/\A<\?php\s*/', "<?php\n" . $phpcs_disable . "\n", $code, 1 );
}

function push_md_add_direct_access_guard( $code, $guard ) {
if (
false !== strpos( $code, "defined( 'ABSPATH' )" ) ||
false !== strpos( $code, "defined( 'WP_UNINSTALL_PLUGIN' )" )
) {
return $code;
}

if ( ! preg_match( '/\A<\?php\b/', $code ) ) {
return $code;
}

if ( preg_match( '/namespace\s+[^;]+;\s*/', $code, $matches, PREG_OFFSET_CAPTURE ) ) {
$insert_at = $matches[0][1] + strlen( $matches[0][0] );
$tail = substr( $code, $insert_at );
if ( preg_match( '/\A(?:(?:\s+)|(?:use\s+[^;]+;\s*))*/', $tail, $use_matches ) ) {
$insert_at += strlen( $use_matches[0] );
}

return substr( $code, 0, $insert_at ) . $guard . substr( $code, $insert_at );
}

if ( preg_match( '/\A<\?php\s*(?:declare\s*\([^;]+;\s*)*/', $code, $matches ) ) {
$insert_at = strlen( $matches[0] );

return substr( $code, 0, $insert_at ) . $guard . substr( $code, $insert_at );
}

return $code;
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$target_dir,
FilesystemIterator::SKIP_DOTS
)
);

foreach ( $iterator as $file ) {
if ( 'php' !== strtolower( $file->getExtension() ) ) {
continue;
}

$path = $file->getPathname();
$relative_path = substr( $path, strlen( $target_dir ) + 1 );
$code = file_get_contents( $path );
if ( false === $code ) {
continue;
}

if ( 0 === strpos( $relative_path, 'php-toolkit/' ) ) {
$code = push_md_add_phpcs_disable( $code, $phpcs_disable );
}

$code = push_md_add_direct_access_guard( $code, $guard );
file_put_contents( $path, $code );
}
PHP
}

copy_pmd_php_toolkit_bundle() {
local target_dir=$1
local manifest_file="$PROJECT_DIR/bin/push-md-toolkit-manifest.txt"
Expand Down Expand Up @@ -255,6 +362,7 @@ copy_pmd_php_toolkit_bundle() {

mkdir -p $DIST_DIR/push-md
rsync -a \
--exclude='.DS_Store' \
--exclude='Tests/' \
--exclude='docker-demo/' \
--exclude='docs/' \
Expand All @@ -264,7 +372,9 @@ rsync -a \
"$PROJECT_DIR/plugins/push-md/" \
"$DIST_DIR/push-md/"
copy_pmd_php_toolkit_bundle "$DIST_DIR/push-md/php-toolkit"
harden_pmd_bundled_php "$DIST_DIR/push-md"
cd $DIST_DIR
zip -r push-md.zip push-md/
bash "$PROJECT_DIR/bin/inspect-push-md-zip.sh" "$DIST_DIR/push-md.zip"
cd $PROJECT_DIR
rm -rf $DIST_DIR/push-md
57 changes: 57 additions & 0 deletions bin/inspect-push-md-zip.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/bin/bash

set -euo pipefail

ZIP_PATH="${1:-dist/plugins/push-md.zip}"

if [ ! -f "$ZIP_PATH" ]; then
echo "Missing Push MD zip: $ZIP_PATH" >&2
exit 1
fi

CONTENTS_FILE="$(mktemp)"
trap 'rm -f "$CONTENTS_FILE"' EXIT

zipinfo -1 "$ZIP_PATH" > "$CONTENTS_FILE"

require_file() {
local path="$1"
if ! grep -Fxq "$path" "$CONTENTS_FILE"; then
echo "Push MD zip is missing required file: $path" >&2
exit 1
fi
}

reject_pattern() {
local pattern="$1"
local message="$2"
local matches

matches="$(grep -E "$pattern" "$CONTENTS_FILE" || true)"
if [ -n "$matches" ]; then
echo "$message" >&2
echo "$matches" >&2
exit 1
fi
}

require_file 'push-md/push-md.php'
require_file 'push-md/readme.txt'
require_file 'push-md/php-toolkit/vendor/composer/ClassLoader.php'
require_file 'push-md/php-toolkit/components/Markdown/class-markdownconsumer.php'
require_file 'push-md/php-toolkit/components/Markdown/vendor-patched/league/commonmark/LICENSE'
require_file 'push-md/php-toolkit/components/Markdown/vendor-patched/league/config/LICENSE.md'
require_file 'push-md/php-toolkit/components/Markdown/vendor-patched/nette/schema/license.md'
require_file 'push-md/php-toolkit/components/Markdown/vendor-patched/nette/utils/license.md'

reject_pattern '(^|/)\.DS_Store$' 'Push MD zip must not contain macOS metadata files.'
reject_pattern '\.phar$' 'Push MD zip must not contain PHAR archives.'
reject_pattern '^push-md/(Tests|docker-demo|docs)/' 'Push MD zip must not contain development-only plugin directories.'
reject_pattern '^push-md/(blueprint-e2e\.json|push-md-dev-bootstrap\.php|push-md-phar-bootstrap\.php)$' 'Push MD zip contains development or PHAR bootstrap files.'
reject_pattern '(^|/)(composer\.(json|lock)|package(-lock)?\.json|phpunit\.xml(\.dist)?|phpcs\.xml|rector\.php)$' 'Push MD zip contains source-only project metadata.'
reject_pattern 'components/Markdown/class-markdownimporter\.php$' 'Push MD zip contains the unused Markdown importer.'
reject_pattern 'vendor-patched/(bin/|composer/|webuni/|symfony/yaml/)' 'Push MD zip contains pruned vendor support files or front matter/YAML dependencies.'
reject_pattern 'vendor-patched/league/commonmark/src/Extension/(Attributes|DefaultAttributes|DescriptionList|Embed|Footnote|FrontMatter|HeadingPermalink|Mention|SmartPunct|TableOfContents)/' 'Push MD zip contains pruned CommonMark extensions.'
reject_pattern 'vendor-patched/nette/utils/src/Iterators/' 'Push MD zip contains pruned Nette iterator utilities.'

echo "Push MD zip contents look submission-ready: $ZIP_PATH"
6 changes: 3 additions & 3 deletions bin/push-md-toolkit-manifest.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Push MD ships a purpose-built toolkit bundle, not the entire monorepo.
# Keep Markdown vendored code intact for now; the rest of this list is the
# runtime surface Push MD uses for Git storage, Git Smart HTTP, and block
# markup conversion.
# The build script prunes unused Markdown/front matter vendor files from this
# readable runtime bundle. The rest of this list is the runtime surface Push MD
# uses for Git storage, Git Smart HTTP, and block markup conversion.

components/Markdown/

Expand Down
6 changes: 6 additions & 0 deletions components/Filesystem/Tests/WpdbFilesystemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,10 @@ public function testGetMetaExposesTableNames() {
$this->assertSame( 'pmd_files', $meta['files_table'] );
$this->assertSame( 'pmd_directory_entries', $meta['entries_table'] );
}

public function testRejectsUnsafeTablePrefix() {
$this->expectException( InvalidArgumentException::class );

WpdbFilesystem::create( $this->wpdb, 'pmd_;DROP_' );
}
}
25 changes: 19 additions & 6 deletions components/Filesystem/class-wpdbfilesystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

namespace WordPress\Filesystem;

// Table names are configured per-instance and cannot be passed through
// $wpdb->prepare(). All user-supplied values use placeholders.
// Table names are configured per-instance, validated as SQL identifiers, and
// interpolated only after validation. All user-supplied values use placeholders.
// Direct queries are intentional here: this class is a private Git object store.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

use Exception;
use WordPress\ByteStream\MemoryPipe;
Expand Down Expand Up @@ -63,16 +67,25 @@ public static function create( $wpdb, $table_prefix ) {
* import. The next call to `create()` will re-create the schema.
*/
public static function drop_tables( $wpdb, $table_prefix ) {
$files_table = $table_prefix . 'files';
$entries_table = $table_prefix . 'directory_entries';
$files_table = self::sanitize_table_identifier( $table_prefix . 'files' );
$entries_table = self::sanitize_table_identifier( $table_prefix . 'directory_entries' );
$wpdb->query( "DROP TABLE IF EXISTS {$files_table}" );
$wpdb->query( "DROP TABLE IF EXISTS {$entries_table}" );
}

private function __construct( $wpdb, $table_prefix ) {
$this->wpdb = $wpdb;
$this->files_table = $table_prefix . 'files';
$this->entries_table = $table_prefix . 'directory_entries';
$this->files_table = self::sanitize_table_identifier( $table_prefix . 'files' );
$this->entries_table = self::sanitize_table_identifier( $table_prefix . 'directory_entries' );
}

private static function sanitize_table_identifier( $table ) {
$table = (string) $table;
if ( ! preg_match( '/\A[A-Za-z0-9_]+\z/', $table ) ) {
throw new \InvalidArgumentException( 'Invalid database table identifier.' );
}

return $table;
}

public function get_root(): string {
Expand Down
43 changes: 43 additions & 0 deletions components/Markdown/Tests/MarkdownConsumerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,49 @@ public function test_frontmatter_extraction() {
$this->assertEquals( $expected_metadata, $metadata );
}

public function test_frontmatter_subset_extraction() {
$markdown = <<<MD
---
title: 'It''s ready'
menu_order: 0
description:
---

Content
MD;
$consumer = new MarkdownConsumer( $markdown );
$result = $consumer->consume();

$this->assertEquals(
array(
'title' => array( "It's ready" ),
'menu_order' => array( 0 ),
'description' => array( '' ),
),
$result->get_all_metadata()
);
}

public function test_frontmatter_nested_values_are_exposed_for_caller_validation() {
$markdown = <<<MD
---
title:
- "Array Title"
---

Content
MD;
$consumer = new MarkdownConsumer( $markdown );
$result = $consumer->consume();

$this->assertEquals(
array(
'title' => array( array() ),
),
$result->get_all_metadata()
);
}

public function test_gutenberg_fence_is_preserved_as_block_markup() {
$markdown = <<<MD
```gutenberg
Expand Down
Loading
Loading