Skip to content

Commit 47ebd65

Browse files
faisalahammadclaude
andcommitted
Merge branch 'main' (upstream) into fix/837-rest-url-imports-route-through-ability
Rebase onto Automattic's actual main to resolve PR Automattic#842's merge conflict. Re-resolved tests/smoke-importer-block.php playground URL test on the rest.php URL router that short-circuits to requires_ability_capable_target envelope (URL imports now route through the unified static-site-importer/import ability; the playground preview path returns a structured ability_capable_target_required envelope instead of a real blueprint). Co-Authored-By: Claude <noreply@anthropic.com>
2 parents fbbf656 + e37ef04 commit 47ebd65

40 files changed

Lines changed: 2494 additions & 1541 deletions

.github/workflows/release-php-wasm-zstd.yml

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ jobs:
5151
cache: npm
5252
- run: npm ci
5353
- name: Publish the immutable safe blueprint
54+
env:
55+
GH_TOKEN: ${{ github.token }}
5456
run: |
5557
pages_dir="$RUNNER_TEMP/static-site-importer-pages"
5658
if git fetch origin gh-pages; then
@@ -62,14 +64,27 @@ jobs:
6264
fi
6365
touch "$pages_dir/.nojekyll"
6466
mkdir -p "$pages_dir/playground"
67+
package_digest="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.assets[] | select(.name == "static-site-importer.zip") | .digest')"
68+
package_sha256="${package_digest#sha256:}"
69+
if ! printf '%s' "$package_sha256" | grep -Eq '^[a-f0-9]{64}$'; then
70+
echo "Release asset static-site-importer.zip must expose a SHA-256 digest." >&2
71+
exit 1
72+
fi
6573
# shellcheck disable=SC2016
6674
node -e '
6775
const fs = require("node:fs");
68-
const [source, output, tag] = process.argv.slice(1);
76+
const [source, output, tag, digest] = process.argv.slice(1);
6977
const blueprint = JSON.parse(fs.readFileSync(source, "utf8"));
70-
blueprint.steps.find((step) => step.step === "installPlugin").pluginData.url = `https://github.com/Automattic/static-site-importer/releases/download/${tag}/static-site-importer.zip`;
78+
const replace = (value) => value.replaceAll("{{RELEASE_TAG}}", tag).replaceAll("{{PACKAGE_SHA256}}", digest);
79+
for (const step of blueprint.steps) {
80+
if (step.data?.url) step.data.url = replace(step.data.url);
81+
if (step.pluginData?.url) step.pluginData.url = replace(step.pluginData.url);
82+
if (step.pluginData?.path) step.pluginData.path = replace(step.pluginData.path);
83+
if (step.path) step.path = replace(step.path);
84+
if (step.code) step.code = replace(step.code);
85+
}
7186
fs.writeFileSync(output, JSON.stringify(blueprint, null, 2) + "\n");
72-
' docs/playground/blueprint.json "$pages_dir/playground/$RELEASE_TAG.blueprint.json" "$RELEASE_TAG"
87+
' docs/playground/blueprint.json "$pages_dir/playground/$RELEASE_TAG.blueprint.json" "$RELEASE_TAG" "$package_sha256"
7388
git -C "$pages_dir" add .nojekyll "playground/$RELEASE_TAG.blueprint.json"
7489
if ! git -C "$pages_dir" diff --cached --quiet; then
7590
git -C "$pages_dir" -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "Publish safe Playground blueprint for $RELEASE_TAG"
@@ -85,8 +100,10 @@ jobs:
85100
process.stdin.on("data", (chunk) => body += chunk);
86101
process.stdin.on("end", () => {
87102
const blueprint = JSON.parse(body);
103+
const download = blueprint.steps.find((step) => step.step === "writeFile");
88104
const plugin = blueprint.steps.find((step) => step.step === "installPlugin");
89-
if (plugin?.pluginData?.url !== process.env.EXPECTED_PLUGIN_URL) process.exit(1);
105+
const check = blueprint.steps.find((step) => step.step === "runPHP" && step.code.includes("hash_file"));
106+
if (download?.data?.url !== process.env.EXPECTED_PLUGIN_URL || plugin?.pluginData?.resource !== "vfs" || !check) process.exit(1);
90107
});
91108
'; then exit 0; fi
92109
echo "Waiting for immutable safe blueprint ($attempt/30)"
@@ -130,8 +147,10 @@ jobs:
130147
process.stdin.on("data", (chunk) => body += chunk);
131148
process.stdin.on("end", () => {
132149
const blueprint = JSON.parse(body);
150+
const download = blueprint.steps.find((step) => step.step === "writeFile");
133151
const plugin = blueprint.steps.find((step) => step.step === "installPlugin");
134-
if (plugin?.pluginData?.url !== process.env.EXPECTED_PLUGIN_URL) process.exit(1);
152+
const check = blueprint.steps.find((step) => step.step === "runPHP" && step.code.includes("hash_file"));
153+
if (download?.data?.url !== process.env.EXPECTED_PLUGIN_URL || plugin?.pluginData?.resource !== "vfs" || !check) process.exit(1);
135154
});
136155
'; then exit 0; fi
137156
echo "Waiting for safe README blueprint alias ($attempt/30)"

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Static Site Importer is a WordPress plugin. It requires the [Blocks Engine PHP t
1212

1313
The initial `website-artifact-import` profile provides the artifact import, validation, WordPress site-plan materialization, and manifest-inspection abilities. It includes SSI's WordPress/PHP runtime and the Blocks Engine PHP transformer dependency while excluding Figma, tests, tools, docs, Node dependencies, and other development-only trees. The same immutable contract is available from `static-site-importer/get-runtime-package-manifest` for runtime discovery.
1414

15+
## Playground package integrity
16+
17+
Generated Playground previews accept an SSI package only when it declares `url`, `version`, and a SHA-256 `sha256` (or `digest`) value. Production selection accepts either the exact GitHub release asset URL for that version or a content-addressed URL containing the declared digest. Playground downloads the archive to its virtual filesystem, verifies its SHA-256 before `installPlugin`, and records the version, digest, and URL in the preview request provenance.
18+
19+
Hosts provide the package through the `static_site_importer_playground_package` filter or the public blueprint primitive's explicit `package` option. A bundled runtime passes `install => false`; this remains the WordPress Build content-addressed package flow and never downloads a second SSI archive. Development-only package URLs require an explicit `development => true` selection and still require a valid digest. Mutable aliases such as `releases/latest` are rejected.
20+
1521
## Canonical Site Plans
1622

1723
`static-site-importer/materialize-wordpress-site-plan` is the generic plan-only boundary for a `blocks-engine/wordpress-site-plan/v2` produced by Blocks Engine 0.4.4. SSI calls the package's canonical validator and resolver, then owns WordPress/filesystem preflight, materialization, reconciliation, and the `static-site-importer/materialization-receipt/v1` response. It accepts no source HTML or transformer result envelope.
@@ -22,6 +28,12 @@ For an isolated runtime matrix, invoke the ability with `plan`, `slug`, and opti
2228
wp static-site-importer materialize-wordpress-site-plan --plan=/path/to/plan.json --slug=generated-site
2329
```
2430

31+
## Client Script Policy
32+
33+
Every artifact is passed through `client_script_policy` before Blocks Engine compilation and WordPress materialization. The default is `inert`: SSI removes executable inline, local, remote, module, telemetry, and `data:` script markup, removes bundled JavaScript assets, and records each disposition in `import_report.client_script_policy`. JSON data scripts are quarantined in the report and are not emitted into the generated site.
34+
35+
`isolated_preview` is the sole preservation opt-in. It requires an explicit `client_script_provenance` object with a non-empty `ref` and a runtime isolation assertion. It is intended only for an isolated disposable preview runtime. Preserved scripts remain `untrusted_imported_code`; artifact carriage, local paths, and source type never establish trust. Current-site REST imports forcibly use `inert`. Existing `include_scripts` URL collection callers no longer preserve scripts; callers must request `script_policy: isolated_preview`, supply provenance, and run only in an isolated preview environment.
36+
2537
## Architecture Stack
2638

2739
Static Site Importer is the WordPress materialization layer for static website inputs. It accepts two related shapes:
@@ -34,6 +46,10 @@ The conversion stack is split by responsibility:
3446
- **Static Site Importer** owns WordPress intake, safety checks, page/theme creation, asset placement, import reports, quality gates, and block-theme materialization.
3547
- **Blocks Engine PHP transformer** owns the generic `ArtifactCompiler`, its diagnostics, and the `source_reports.wordpress_site_plan` v2 output. SSI materializes that plan into WordPress and returns the receipt and import report.
3648

49+
## Content-Only Security Boundary
50+
51+
All HTML, folders, ZIPs, URLs, and website artifact objects are untrusted static content. SSI accepts only explicit static asset extensions and rejects server-side source markers before compilation. Compiler-produced companion payloads are independently revalidated before any generated plugin file is written or activated. Companion block renders accept static HTML only; SSI emits its own fixed PHP wrapper to output that markup, so source PHP cannot be preserved or executed. Existing payloads that relied on PHP render templates or PHP companion assets must migrate their behavior to blocks, data bindings, or client-side JavaScript.
52+
3753
When a generated artifact contains full-document HTML, Static Site Importer routes document metadata, head content, styles, scripts, and page body fragments to the right WordPress destinations before calling the conversion stack. A `core/html` block in imported page content is therefore a materialization/conversion quality issue to fix in this stack, not a product-layer workaround to hide upstream.
3854

3955
## What It Does

bench/static-site-fixture-matrix.bench.mjs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -407,12 +407,8 @@ export async function runFixtureMatrixBatch({ fixtures, batchIndex, matrix, outp
407407
// Discovery is deliberately a separate, short-lived Codebox runtime. It only
408408
// asks SSI for its registry-derived plan; package resolution happens on the
409409
// host while assembling the following fresh import runtime.
410-
const dependencyPlan = options.hostDependencyOrchestration
411-
? await discoverFixtureDependencyPlan({ fixtures, outputDirectory, staticSiteImporterPath, options, batchSuffix })
412-
: undefined;
413-
const resolvedDependencyPlan = dependencyPlan
414-
? await resolveHostDependencyPlan(dependencyPlan, path.join(outputDirectory, 'dependency-cache'))
415-
: undefined;
410+
const dependencyPlan = await discoverFixtureDependencyPlan({ fixtures, outputDirectory, staticSiteImporterPath, options, batchSuffix });
411+
const resolvedDependencyPlan = await resolveHostDependencyPlan(dependencyPlan, path.join(outputDirectory, 'dependency-cache'));
416412
const batchRecipe = buildFixtureMatrixRecipe({
417413
matrix: batchMatrix,
418414
runId: batchMatrix.id,

docs/fixture-matrix.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,14 @@ complete solved corpus. The replayable plan and operator summary identify this
439439
lane as `fixtures-solved-only/v1` and include active, solved, and selected corpus
440440
counts plus the full coverage inventory so CI artifacts prove the exact selection.
441441

442+
The solved-candidate gate also proves persisted Gutenberg editability. After
443+
visual parity capture, it inserts a fixture-specific paragraph through
444+
`wordpress.editor-actions`, saves with `core/editor.savePost`, reloads the editor,
445+
and captures the reloaded state. A required runtime assertion verifies the marker
446+
in the persisted front-page `post_content`, then `wp.blocks.validateBlock` runs
447+
again against the post-save document. Any action, persistence, reload, or
448+
post-save block-validity failure fails the fixture.
449+
442450
```bash
443451
node tools/promote-solved-fixture.mjs \
444452
--fixture-id <id> \
@@ -488,16 +496,9 @@ After each fixture's import step, `buildFixtureMatrixRecipe` appends a
488496
`invalid_blocks`. This reuses the existing wp-codebox editor-validation command
489497
rather than rebuilding a validator.
490498

491-
Live-wiring gap (verified by a real local recipe-run): the matrix currently
492-
passes only a bare `post-type=<type>` target. wp-codebox's
493-
`editorOpenTargetFromArgs` resolves a bare `post-type` to an EMPTY
494-
`post-new.php?post_type=<type>` editor, so the pass validates `total_blocks: 0`
495-
and proves nothing about the imported markup. To assert real imported-output
496-
block validity the step must receive a concrete target — most robustly the
497-
imported `post-id` surfaced out of the in-sandbox `validate-artifact` step (or
498-
an inline `content` snapshot of the imported post_content). See
499-
`lib/fixture-matrix/steps/editor-validation-step.mjs` for the target priority
500-
order and the remaining enablement.
499+
The default `front-page` target resolves at runtime to the imported
500+
`page_on_front`, so validation exercises real imported content even though its
501+
post ID is not known while the recipe is generated.
501502

502503
`collectEditorValidationDiagnostics` reads the probe's `selectorSummary`
503504
(invalid-warning matches) — and, when present, per-block `isValid`/`validateBlock`

docs/playground/blueprint.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,22 @@
1313
"step": "login"
1414
},
1515
{
16-
"step": "installPlugin",
17-
"pluginData": {
16+
"step": "writeFile",
17+
"path": "/tmp/static-site-importer-{{PACKAGE_SHA256}}.zip",
18+
"data": {
1819
"resource": "url",
1920
"url": "https://github.com/Automattic/static-site-importer/releases/download/{{RELEASE_TAG}}/static-site-importer.zip"
21+
}
22+
},
23+
{
24+
"step": "runPHP",
25+
"code": "<?php if ( ! hash_equals( '{{PACKAGE_SHA256}}', hash_file( 'sha256', '/tmp/static-site-importer-{{PACKAGE_SHA256}}.zip' ) ) ) { throw new RuntimeException( 'Static Site Importer package integrity verification failed.' ); } ?>"
26+
},
27+
{
28+
"step": "installPlugin",
29+
"pluginData": {
30+
"resource": "vfs",
31+
"path": "/tmp/static-site-importer-{{PACKAGE_SHA256}}.zip"
2032
},
2133
"options": {
2234
"activate": true,

homeboy-test-manifest.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"tests/smoke-ability-import-success-diagnostics.php": { "environment": "standalone-php" },
88
"tests/smoke-ability-registration-idempotent.php": { "environment": "standalone-php" },
99
"tests/smoke-canonical-import-ability.php": { "environment": "standalone-php" },
10+
"tests/smoke-client-script-policy.php": { "environment": "standalone-php" },
11+
"tests/smoke-content-only-policy.php": { "environment": "standalone-php" },
1012
"tests/smoke-companion-plugin-js.php": { "environment": "standalone-php" },
1113
"tests/smoke-companion-plugin.php": { "environment": "standalone-php" },
1214
"tests/smoke-contact-layout-transformer.php": { "environment": "standalone-php" },
@@ -16,6 +18,7 @@
1618
"tests/smoke-import-diagnostic-contract.php": { "environment": "standalone-php" },
1719
"tests/smoke-import-disposition.php": { "environment": "standalone-php" },
1820
"tests/smoke-import-permission-filter.php": { "environment": "standalone-php" },
21+
"tests/smoke-current-site-capabilities.php": { "environment": "standalone-php" },
1922
"tests/smoke-importer-block.php": { "environment": "standalone-php" },
2023
"tests/smoke-inline-svg-materialization.php": { "environment": "standalone-php" },
2124
"tests/smoke-materialized-block-content-validation.php": { "environment": "standalone-php" },
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
/**
3+
* Client script trust policy for imported website artifacts.
4+
*
5+
* @package StaticSiteImporter
6+
*/
7+
8+
if ( ! defined( 'ABSPATH' ) ) {
9+
exit;
10+
}
11+
12+
/** Applies an explicit, provenance-bound client-script policy before compilation. */
13+
class Static_Site_Importer_Client_Script_Policy {
14+
/**
15+
* Make executable client code inert unless an isolated preview explicitly opts in.
16+
*
17+
* @return array{artifact:array<string,mixed>,report:array<string,mixed>}
18+
*/
19+
public static function apply( array $artifact, array $args ): array {
20+
$policy = self::policy_name( $args );
21+
$provenance = self::provenance( $args );
22+
$preserve = 'isolated_preview' === $policy && ! empty( $args['client_script_isolated'] ) && '' !== $provenance;
23+
$report = array(
24+
'schema' => 'static-site-importer/client-script-policy-report/v1',
25+
'policy' => $preserve ? 'isolated_preview' : 'inert',
26+
'trust' => 'untrusted_imported_code',
27+
'provenance' => $preserve ? $provenance : '',
28+
'dropped' => array(),
29+
'quarantined' => array(),
30+
'preserved' => array(),
31+
);
32+
$files = isset( $artifact['files'] ) && is_array( $artifact['files'] ) ? $artifact['files'] : array();
33+
$filtered = array();
34+
35+
foreach ( $files as $file ) {
36+
if ( ! is_array( $file ) ) {
37+
continue;
38+
}
39+
$path = isset( $file['path'] ) && is_scalar( $file['path'] ) ? (string) $file['path'] : '';
40+
if ( self::is_script_file( $file ) ) {
41+
self::record( $report, $preserve ? 'preserved' : 'dropped', self::file_row( $path, $file ) );
42+
if ( ! $preserve ) {
43+
continue;
44+
}
45+
}
46+
if ( self::is_html_file( $file ) ) {
47+
$file['content'] = self::filter_html( (string) ( $file['content'] ?? '' ), $path, $preserve, $report );
48+
}
49+
$filtered[] = $file;
50+
}
51+
52+
$artifact['files'] = $filtered;
53+
return array( 'artifact' => $artifact, 'report' => $report );
54+
}
55+
56+
private static function policy_name( array $args ): string {
57+
return 'isolated_preview' === (string) ( $args['client_script_policy'] ?? '' ) ? 'isolated_preview' : 'inert';
58+
}
59+
60+
private static function provenance( array $args ): string {
61+
$provenance = $args['client_script_provenance'] ?? null;
62+
if ( is_scalar( $provenance ) ) {
63+
return trim( (string) $provenance );
64+
}
65+
if ( is_array( $provenance ) && isset( $provenance['ref'] ) && is_scalar( $provenance['ref'] ) ) {
66+
return trim( (string) $provenance['ref'] );
67+
}
68+
return '';
69+
}
70+
71+
private static function is_html_file( array $file ): bool {
72+
$path = strtolower( (string) ( $file['path'] ?? '' ) );
73+
$mime = strtolower( (string) ( $file['mime_type'] ?? '' ) );
74+
return str_ends_with( $path, '.html' ) || str_ends_with( $path, '.htm' ) || str_contains( $mime, 'html' );
75+
}
76+
77+
private static function is_script_file( array $file ): bool {
78+
$path = strtolower( (string) ( $file['path'] ?? '' ) );
79+
$mime = strtolower( (string) ( $file['mime_type'] ?? '' ) );
80+
return (bool) preg_match( '/\.(?:js|mjs|cjs)$/', $path ) || str_contains( $mime, 'javascript' ) || str_contains( $mime, 'ecmascript' );
81+
}
82+
83+
private static function filter_html( string $html, string $path, bool $preserve, array &$report ): string {
84+
return (string) preg_replace_callback(
85+
'#<script\b([^>]*)>(.*?)</script\s*>#is',
86+
static function ( array $matches ) use ( $path, $preserve, &$report ): string {
87+
$attributes = $matches[1];
88+
$source = self::attribute( $attributes, 'src' );
89+
$type = strtolower( trim( (string) self::attribute( $attributes, 'type' ) ) );
90+
$row = array(
91+
'path' => $path,
92+
'class' => self::script_class( $source, $type, $matches[2] ),
93+
'type' => '' !== $type ? $type : 'classic',
94+
'sha256' => hash( 'sha256', $matches[0] ),
95+
);
96+
if ( null !== $source ) {
97+
$row['src'] = $source;
98+
}
99+
if ( $preserve ) {
100+
self::record( $report, 'preserved', $row );
101+
return $matches[0];
102+
}
103+
self::record( $report, 'data' === $row['class'] ? 'quarantined' : 'dropped', $row );
104+
return '';
105+
},
106+
$html
107+
);
108+
}
109+
110+
private static function attribute( string $attributes, string $name ): ?string {
111+
if ( ! preg_match( '/\s' . preg_quote( $name, '/' ) . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $attributes, $matches ) ) {
112+
return null;
113+
}
114+
return '' !== (string) ( $matches[1] ?? '' ) ? $matches[1] : ( '' !== (string) ( $matches[2] ?? '' ) ? $matches[2] : (string) ( $matches[3] ?? '' ) );
115+
}
116+
117+
private static function script_class( ?string $source, string $type, string $content ): string {
118+
if ( in_array( $type, array( 'application/json', 'application/ld+json', 'application/manifest+json' ), true ) || ( null !== $source && str_starts_with( strtolower( $source ), 'data:' ) ) ) {
119+
return 'data';
120+
}
121+
if ( 'module' === $type ) {
122+
return 'module';
123+
}
124+
if ( preg_match( '/(?:google-analytics|googletagmanager|gtag\s*\(|segment\.|mixpanel|hotjar|clarity|sentry|telemetry|analytics)/i', (string) $source . "\n" . $content ) ) {
125+
return 'telemetry';
126+
}
127+
if ( null === $source ) {
128+
return 'inline';
129+
}
130+
return preg_match( '#^(?:https?:)?//#i', $source ) ? 'remote' : 'local';
131+
}
132+
133+
private static function file_row( string $path, array $file ): array {
134+
return array(
135+
'path' => $path,
136+
'class' => 'local',
137+
'type' => 'asset',
138+
'sha256' => hash( 'sha256', (string) ( $file['content'] ?? '' ) ),
139+
);
140+
}
141+
142+
private static function record( array &$report, string $disposition, array $row ): void {
143+
$report[ $disposition ][] = $row;
144+
}
145+
}

0 commit comments

Comments
 (0)