Просмотр исходного кода

ci: update SRI hashes automatically after pushes to master

Keeping the SRI hashes in lib/Configuration.php current was a manual
step that often got forgotten, and every merge that changed a JS file
caused merge conflicts on the same hash line in other open PRs. The
PHPUnit bootstrap rewrote the hashes, but CI ignored the result.
Instead, regenerate them on master and stop asking for them in PRs
(#2108).

- bin/sri: new PHP script that regenerates the 'sri' array from the
  files in js/: updates changed hashes, adds new files, drops removed
  ones and keeps the array sorted and aligned, so library upgrades
  need no manual edits. --check only reports and exits with status 1
  if anything is outdated. Excluded from release archives.
- .github/workflows/sri.yml: runs bin/sri on every push to master
  (including manual merges from advisory forks) and on manual
  dispatch, then commits and pushes changed hashes. Pushes with a
  write deploy key (secret SRI_DEPLOY_KEY), because the master
  ruleset requires PRs and only allows bypass actors like deploy keys.
  Commits with plain git, so no third-party action gets the key.
- tst/Bootstrap.php: removed Helper::updateSubresourceIntegrity(), the
  unit tests no longer modify lib/Configuration.php.
- Makefile: "make increment" runs bin/sri and stages
  lib/Configuration.php with the version bump, "make sign" runs
  bin/sri --check before tagging, to prevent releases with outdated
  hashes.
- .github/copilot-instructions.md: document bin/sri and the workflow,
  and ask not to commit hash changes in PRs.
El RIDO 3 дней назад
Родитель
Сommit
2208373656
7 измененных файлов с 199 добавлено и 49 удалено
  1. 1 0
      .gitattributes
  2. 9 0
      .github/copilot-instructions.md
  3. 52 0
      .github/workflows/sri.yml
  4. 5 3
      Makefile
  5. 131 0
      bin/sri
  6. 1 1
      lib/Configuration.php
  7. 0 45
      tst/Bootstrap.php

+ 1 - 0
.gitattributes

@@ -1,5 +1,6 @@
 bin/configuration-test-generator export-ignore
 bin/icon-test export-ignore
+bin/sri export-ignore
 doc/ export-ignore
 tst/ export-ignore
 i18n/en.json export-ignore

+ 9 - 0
.github/copilot-instructions.md

@@ -55,6 +55,13 @@
   - Always run after code changes to backend logic.
   - If `vendor/bin/phpunit` does not exist, ensure `composer install` completed without errors.
 
+- **SRI hashes:**
+  ```sh
+  php bin/sri
+  ```
+  - Regenerates the subresource integrity hashes of all JS files in the `sri` array of `lib/Configuration.php`. Browsers refuse to run a changed JS file until its hash matches, so run this before testing JS changes in a browser.
+  - `php bin/sri --check` only reports outdated hashes and exits with status 1 if there are any.
+
 - **JS Tests:**
   ```sh
   cd ./js
@@ -110,12 +117,14 @@
 
 - **GitHub CI:** On PRs, runs `composer install`, `phpunit`, and JS tests.
 - **Validation Steps:** PRs failing tests will be blocked. Always ensure a clean test run before submitting.
+- **SRI hashes:** On every push to `master`, the `sri.yml` workflow runs `bin/sri` and commits updated hashes, so PRs don't need to update them.
 
 ## Guidance for Copilot Agent
 
 - **Trust these instructions.** Only perform a search if information is missing or appears incorrect.
 - **Do NOT use Node.js APIs in production JS code.** Only test code may do so.
 - **Never modernize `legacy.js`.** It must work in very old browsers.
+- **Don't commit SRI hash changes in `lib/Configuration.php`.** They get updated automatically after merging, and committing them only causes merge conflicts between PRs.
 - **Always run `composer install` before PHP tests, and `npm install` before JS tests.**
 - **Validate all changes by running both PHP and JS tests.**
 - **Review `.github/workflows/` for the latest validation pipeline steps.**

+ 52 - 0
.github/workflows/sri.yml

@@ -0,0 +1,52 @@
+name: SRI hashes
+
+# Regenerates the subresource integrity (SRI) hashes in lib/Configuration.php
+# after anything got merged or pushed into master and commits them, if they
+# changed. The push uses a deploy key (secret SRI_DEPLOY_KEY) that may bypass
+# the branch ruleset of master.
+on:
+  push:
+    branches: [ master ]
+    paths:
+      - 'js/*.js'
+      - 'lib/Configuration.php'
+      - 'bin/sri'
+      - '.github/workflows/sri.yml'
+  workflow_dispatch:
+
+permissions: {}
+
+# run one update at a time; a queued run checks out the latest master anyway
+concurrency:
+  group: sri-hashes
+  cancel-in-progress: false
+
+jobs:
+  update:
+    name: Update SRI hashes
+    if: github.repository == 'PrivateBin/PrivateBin'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v7
+        with:
+          ref: master
+          ssh-key: ${{ secrets.SRI_DEPLOY_KEY }}
+          # the deploy key is needed for the push below, no third party actions get run after this
+          persist-credentials: true
+
+      - name: Update SRI hashes
+        run: php bin/sri
+
+      - name: Commit and push changed hashes
+        run: |
+          if git diff --quiet -- lib/Configuration.php; then
+            echo "SRI hashes are up to date, nothing to commit."
+            exit 0
+          fi
+          git config user.name 'PrivateBin SRI Bot'
+          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+          git commit --message 'chore: update SRI hashes' -- lib/Configuration.php
+          git push origin HEAD:master

+ 5 - 3
Makefile

@@ -28,15 +28,17 @@ doc-js: ## Generate JS code documentation.
 doc-php: ## Generate JS code documentation.
 	phpdoc --visibility=public,protected,private --target=doc/phpdoc --directory=lib/
 
-increment: ## Increment and commit new version number, set target version using `make increment VERSION=1.2.3`.
+increment: ## Increment version number, update SRI hashes and commit, set target version using `make increment VERSION=1.2.3`.
 	for F in `grep -l -R $(REGEX_CURRENT_VERSION) $(VERSION_FILES)`; \
 	do \
 		sed -i "s/$(REGEX_CURRENT_VERSION)/$(REGEX_VERSION)/g" $$F; \
 	done
-	git add $(VERSION_FILES) CHANGELOG.md
+	php bin/sri
+	git add $(VERSION_FILES) CHANGELOG.md lib/Configuration.php
 	git commit -m "incrementing version"
 
-sign: ## Sign a release.
+sign: ## Check SRI hashes and sign a release.
+	php bin/sri --check
 	git tag --sign --message "Release v$(VERSION)" $(VERSION)
 	git push origin $(VERSION)
 	signrelease.sh

+ 131 - 0
bin/sri

@@ -0,0 +1,131 @@
+#!/usr/bin/env php
+<?php declare(strict_types=1);
+/**
+ * PrivateBin
+ *
+ * a zero-knowledge paste bin
+ *
+ * @link      https://github.com/PrivateBin/PrivateBin
+ * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
+ * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
+ */
+
+/**
+ * Regenerates the subresource integrity (SRI) hashes of all JavaScript files
+ * in the 'sri' array of lib/Configuration.php. Entries for new files get
+ * added, entries for removed files get dropped and the array is kept sorted.
+ * On master this is run by the GitHub workflow .github/workflows/sri.yml.
+ */
+
+define('PATH', dirname(__FILE__) . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR);
+
+// JS files that browsers never load, so they need no SRI hash
+const IGNORED_FILES = ['common.js', 'eslint.config.js'];
+
+$opts = getopt('ch', ['check', 'help']);
+if ($opts === false) {
+    fail('Erroneous command line options. Please use --help');
+}
+if (array_key_exists('h', $opts) || array_key_exists('help', $opts)) {
+    echo <<<'EOT'
+Usage:
+  sri [--check]
+
+Regenerates the SRI hashes of all JavaScript files in js/ and writes them into
+the 'sri' array of lib/Configuration.php.
+
+Options:
+  -c, --check   don't write anything, exit with status 1 if any hash is
+                outdated, missing or superfluous
+  -h, --help    displays this help message
+
+EOT;
+    exit(0);
+}
+$check = array_key_exists('c', $opts) || array_key_exists('check', $opts);
+
+// calculate the hashes of all JS files
+$hashes = [];
+foreach (glob(PATH . 'js' . DIRECTORY_SEPARATOR . '*.js') as $file) {
+    $name = basename($file);
+    if (in_array($name, IGNORED_FILES, true)) {
+        continue;
+    }
+    $hashes['js/' . $name] = 'sha512-' . base64_encode(
+        hash('sha512', file_get_contents($file), true)
+    );
+}
+if (count($hashes) === 0) {
+    fail('No JS files found in ' . PATH . 'js');
+}
+ksort($hashes, SORT_STRING);
+
+// find the 'sri' array in the configuration defaults
+$file    = PATH . 'lib' . DIRECTORY_SEPARATOR . 'Configuration.php';
+$content = file_get_contents($file);
+if ($content === false) {
+    fail('Could not read ' . $file);
+}
+if (preg_match(
+    '#^( *)\'sri\' => \[\n(.*?)^\1\],\n#ms',
+    $content, $matches, PREG_OFFSET_CAPTURE
+) !== 1) {
+    fail('Could not find the \'sri\' array in ' . $file);
+}
+$indent  = $matches[1][0] . '    ';
+$block   = $matches[2][0];
+$start   = $matches[2][1];
+$current = [];
+preg_match_all('#\'(js/[^\']+)\' *=> \'([^\']*)\',#', $block, $entries, PREG_SET_ORDER);
+foreach ($entries as $entry) {
+    $current[$entry[1]] = $entry[2];
+}
+
+// generate the new array content, aligning the arrows
+$width = max(array_map('strlen', array_keys($hashes))) + 2;
+$lines = '';
+foreach ($hashes as $key => $hash) {
+    $lines .= $indent . str_pad('\'' . $key . '\'', $width) . ' => \'' . $hash . '\',' . "\n";
+}
+
+// report the differences
+$changes = 0;
+foreach ($hashes as $key => $hash) {
+    if (!array_key_exists($key, $current)) {
+        echo 'added: ', $key, PHP_EOL;
+        ++$changes;
+    } elseif ($current[$key] !== $hash) {
+        echo 'updated: ', $key, PHP_EOL;
+        ++$changes;
+    }
+}
+foreach (array_diff_key($current, $hashes) as $key => $hash) {
+    echo 'removed: ', $key, PHP_EOL;
+    ++$changes;
+}
+if ($lines === $block) {
+    echo 'SRI hashes are up to date.', PHP_EOL;
+    exit(0);
+}
+if ($check) {
+    fail('SRI hashes in ' . $file . ' are outdated, run bin/sri to update them.', 1);
+}
+if ($changes === 0) {
+    echo 'reformatted the \'sri\' array', PHP_EOL;
+}
+$content = substr($content, 0, $start) . $lines . substr($content, $start + strlen($block));
+if (file_put_contents($file, $content) === false) {
+    fail('Could not write ' . $file);
+}
+
+/**
+ * print an error to STDERR and exit
+ *
+ * @param string $message
+ * @param int $code
+ */
+function fail(string $message, int $code = 2): void
+{
+    fwrite(STDERR, $message . PHP_EOL);
+    exit($code);
+}

+ 1 - 1
lib/Configuration.php

@@ -111,7 +111,7 @@ class Configuration
             'apikey'    => '',
             'apiurl'    => '',
         ],
-        // update this array when adding/changing/removing js files
+        // generated by bin/sri, which runs automatically on the master branch
         'sri' => [
             'js/base-x-5.0.1.js'     => 'sha512-FmhlnjIxQyxkkxQmzf0l6IRGsGbgyCdgqPxypFsEtHMF1naRqaLLo6mcyN5rEaT16nKx1PeJ4g7+07D6gnk/Tg==',
             'js/bootstrap-5.3.8.js'  => 'sha512-BkZvJ5rZ3zbDCod5seWHpRGg+PRd6ZgE8Nua/OMtcxqm8Wtg0PqwhUUXK5bqvl3oclMt5O+3zjRVX0L+L2j7fA==',

+ 0 - 45
tst/Bootstrap.php

@@ -27,7 +27,6 @@ if (!defined('CONF_SAMPLE')) {
 }
 
 require PATH . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
-Helper::updateSubresourceIntegrity();
 TemplateSwitcher::setAvailableTemplates(Configuration::getDefaults()['main']['availabletemplates']);
 
 /**
@@ -94,13 +93,6 @@ class Helper
         'created' => 1344803528,
     ];
 
-    /**
-     * JS files and their SRI hashes
-     *
-     * @var array
-     */
-    private static $hashes = [];
-
     /**
      * get example paste ID
      *
@@ -341,43 +333,6 @@ class Helper
             return var_export($var, $return);
         }
     }
-
-    /**
-     * update all templates with the latest SRI hashes for all JS files
-     *
-     * @return void
-     */
-    public static function updateSubresourceIntegrity(): void
-    {
-        foreach (new GlobIterator(PATH . 'js' . DIRECTORY_SEPARATOR . '*.*js') as $file) {
-            if ($file->getBasename() === 'common.js' || $file->getBasename() === 'eslint.config.js') {
-                continue; // ignore JS unit test bootstrap
-            }
-            self::$hashes[$file->getBasename()] = base64_encode(
-                hash('sha512', file_get_contents($file->getPathname()), true)
-            );
-        }
-
-        $counter = 0;
-        $file = PATH . 'lib' . DIRECTORY_SEPARATOR . 'Configuration.php';
-        $content = preg_replace_callback(
-            '#\'js/([a-z0-9.-]+.js)(\' +)=\> \'[^\']*\',#',
-            function ($matches) use (&$counter) {
-                if (array_key_exists($matches[1], Helper::$hashes)) {
-                    ++$counter;
-                    return '\'js/' . $matches[1] . $matches[2] .
-                        '=> \'sha512-' . Helper::$hashes[$matches[1]] . '\',';
-                } else {
-                    throw new Exception('SRI hash for file js/' . $matches[1] . ' not found, please add the missing file or remove it from lib/Configuration.php.');
-                }
-            },
-            file_get_contents($file)
-        );
-        file_put_contents($file, $content);
-        if ($counter !== count(self::$hashes)) {
-            throw new Exception('Mismatch between ' . count(self::$hashes) . ' found js files and ' . $counter . ' SRI hashes in lib/Configuration.php, please update lib/Configuration.php to match the list of js files.');
-        }
-    }
 }
 
 /**