Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.18% covered (success)
95.18%
217 / 228
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wpcom_Feature_Flags
95.18% covered (success)
95.18%
217 / 228
72.22% covered (warning)
72.22%
13 / 18
79
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_a11n
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 current_user_can_manage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_overrides
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 save_overrides
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 reset_overrides_cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 overrides_from_states
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 filter_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_admin_page
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 handle_admin_post
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_handle_submission
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 handle_save
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 render_admin_page
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 print_screen
99.06% covered (success)
99.06%
105 / 106
0.00% covered (danger)
0.00%
0 / 1
16
 is_support_session
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 sanitize_overrides
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 is_valid_flag_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_rows
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * AUTOMATTICIANS ONLY. Internal Automattic tooling for flipping Jetpack feature
4 * flags on WordPress.com Simple and Atomic sites.
5 *
6 * This is not a WordPress.com feature and is not supported. Site owners never
7 * see it: the screen, its menu entry, and its save path are each gated on the
8 * Automattician check below, which fails closed. Nothing here should ever be
9 * documented for, linked to, or demonstrated to anyone outside Automattic.
10 *
11 * The jetpack-feature-flags package is a registry: it resolves every flag
12 * through the `jetpack_feature_flag_enabled` filter and deliberately stores no
13 * state of its own. Nothing on WordPress.com answered that filter, so a flag
14 * could only be flipped by shipping a code change or a sandbox patch.
15 *
16 * This feature adds the missing control surface, on Simple and Atomic both:
17 * a Tools -> Feature Flags screen listing the registered flags with a
18 * three-state control each.
19 *
20 * Two properties are load-bearing:
21 *
22 * - The *screen* is Automattician-only and fails closed. Without the wpcom
23 *   platform primitives that identify an Automattician, nobody sees it.
24 * - The *overrides* are site-wide and are NOT re-gated on the Automattician
25 *   check. That is the point: an override has to change what the site actually
26 *   does, logged-out visitors included, or it cannot be used to test a flag
27 *   end to end. It also means an override changes what the site's owner sees,
28 *   which is why the screen says so in as many words.
29 *
30 * Strings here are intentionally not translated. This is internal Automattician
31 * tooling, and putting its debug copy in front of GlotPress volunteers would
32 * waste their time.
33 *
34 * @package automattic/jetpack-mu-wpcom
35 */
36
37namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
38
39use Automattic\Jetpack\Constants;
40use Automattic\Jetpack\Feature_Flags\Feature_Flags;
41use Automattic\Jetpack\Status\Host;
42use WPCOMSH_Support_Session_Detect;
43
44/**
45 * Site-wide feature flag overrides, and the Automattician-only screen that sets them.
46 *
47 * AUTOMATTICIANS ONLY — internal Automattic tooling, not a supported
48 * user-facing feature. Every entry point that exposes the screen or writes an
49 * override goes through current_user_can_manage(); do not add one that skips it.
50 *
51 * (Deliberately not tagged `@internal`: Phan reads that as "callable only from
52 * this namespace" and rejects the loader in \Automattic\Jetpack calling init().)
53 */
54class Wpcom_Feature_Flags {
55
56    /**
57     * Option holding the site's flag overrides, as a `flag name => bool` map.
58     *
59     * Stored non-autoloaded and deleted outright when the last override is
60     * removed, so the overwhelming majority of sites — which will never open
61     * this screen — carry no row and pay nothing for it.
62     */
63    const OVERRIDES_OPTION = 'wpcom_feature_flag_overrides';
64
65    /**
66     * Nonce action guarding the override form.
67     */
68    const NONCE_ACTION = 'wpcom-feature-flags-save';
69
70    /**
71     * Admin page slug.
72     */
73    const PAGE_SLUG = 'wpcom-feature-flags';
74
75    /**
76     * Capability required on top of the Automattician gate.
77     *
78     * Overrides change site behaviour, so hold the screen to the same bar as any
79     * other site-wide setting rather than leaning on the gate alone.
80     */
81    const CAPABILITY = 'manage_options';
82
83    /**
84     * Cache of one blog's sanitized override map; valid only for $overrides_blog_id.
85     *
86     * Keyed by blog rather than dropped on `switch_blog`: WordPress.com's public API
87     * resolves flags before switching to the requested site, and a hook only helps
88     * if it fires, and fires before any other callback that resolves a flag.
89     *
90     * @var array<array-key, bool>|null
91     */
92    private static $overrides = null;
93
94    /**
95     * The blog $overrides was read for. Null means nothing is cached.
96     *
97     * @var int|null
98     */
99    private static $overrides_blog_id = null;
100
101    /**
102     * Register the hooks this feature needs.
103     *
104     * @return void
105     */
106    public static function init() {
107        add_filter( 'jetpack_feature_flag_enabled', array( self::class, 'filter_enabled' ), 10, 2 );
108        add_action( 'admin_menu', array( self::class, 'register_admin_page' ) );
109    }
110
111    /**
112     * Whether the current visitor is an Automattician.
113     *
114     * Mirrors the platform split already used by do_not_track_a11ns() in
115     * wpcom-wpadmin-page-view.php: on Simple the platform's own
116     * is_automattician() is authoritative, and on Atomic the a8c proxy is what
117     * identifies us. Both branches fail closed when their primitive is missing.
118     *
119     * A support session reaches an Atomic site through the same proxy, but it is
120     * a Happiness Engineer acting on the site owner's behalf rather than an
121     * Automattician testing unreleased work, so it is excluded. That exclusion
122     * fails closed too — see is_support_session() for why it cannot just ask
123     * wpcomsh and believe the answer.
124     *
125     * AT_PROXIED_REQUEST is read through Constants rather than defined() — which
126     * is what the neighbouring code uses — so the Atomic branch is reachable from
127     * tests at all. That is safe here because Constants::set_constant() is only
128     * callable by code already executing in this process, which by then can do
129     * anything this gate protects, and because manage_options is required on top.
130     * Do not lean on this gate alone for anything stronger.
131     *
132     * @return bool Whether the current visitor is an Automattician.
133     */
134    public static function is_a11n() {
135        if ( ( new Host() )->is_wpcom_simple() ) {
136            return function_exists( 'is_automattician' ) && (bool) is_automattician();
137        }
138
139        if ( ! Constants::is_true( 'AT_PROXIED_REQUEST' ) ) {
140            return false;
141        }
142
143        return ! self::is_support_session();
144    }
145
146    /**
147     * Whether the current user may read and change this site's flag overrides.
148     *
149     * @return bool Whether the current user may manage flag overrides.
150     */
151    public static function current_user_can_manage() {
152        return self::is_a11n() && current_user_can( self::CAPABILITY );
153    }
154
155    /**
156     * Return this site's flag overrides.
157     *
158     * Memoized per blog. filter_enabled() runs once per flag resolution,
159     * and the screen resolves every listed flag to fill its Effective column, so
160     * without this the option read, the per-entry preg_match(), and the ksort()
161     * in sanitize_overrides() all repeat for every flag checked. The option
162     * cannot change mid-request except through save_overrides(), which clears
163     * this. Code that writes the option behind our back — wp-cli, a direct
164     * update_option() — is picked up on the next request.
165     *
166     * @return array<array-key, bool> Map of flag name to forced value.
167     */
168    public static function get_overrides() {
169        $blog_id = get_current_blog_id();
170
171        if ( null !== self::$overrides && self::$overrides_blog_id === $blog_id ) {
172            return self::$overrides;
173        }
174
175        $stored = get_option( self::OVERRIDES_OPTION );
176
177        self::$overrides         = is_array( $stored ) ? self::sanitize_overrides( $stored ) : array();
178        self::$overrides_blog_id = $blog_id;
179
180        return self::$overrides;
181    }
182
183    /**
184     * Persist this site's flag overrides, replacing whatever was stored.
185     *
186     * @param array<array-key, bool> $overrides Map of flag name to forced value.
187     * @return void
188     */
189    public static function save_overrides( array $overrides ) {
190        $overrides = self::sanitize_overrides( $overrides );
191
192        self::$overrides         = null;
193        self::$overrides_blog_id = null;
194
195        if ( empty( $overrides ) ) {
196            delete_option( self::OVERRIDES_OPTION );
197
198            return;
199        }
200
201        update_option( self::OVERRIDES_OPTION, $overrides, false );
202    }
203
204    /**
205     * Forget the memoized override map.
206     *
207     * Intended for tests, which write the option directly rather than through
208     * save_overrides(). Mirrors Feature_Flags::reset() in the registry package.
209     *
210     * @return void
211     */
212    public static function reset_overrides_cache() {
213        self::$overrides         = null;
214        self::$overrides_blog_id = null;
215    }
216
217    /**
218     * Turn the form's three-state controls into an override map.
219     *
220     * "default" means the absence of an override rather than an override to the
221     * flag's current default, so a flag left alone keeps following whatever the
222     * code that registered it decides later.
223     *
224     * @param array $states Map of flag name to 'on', 'off', or 'default'.
225     * @return array<array-key, bool> Override map.
226     */
227    public static function overrides_from_states( array $states ) {
228        $overrides = array();
229
230        foreach ( $states as $name => $state ) {
231            /*
232             * PHP casts a decimal-integer array key to int, so an all-digit flag
233             * name — which the documented ^[a-z0-9][a-z0-9_-]*$ pattern allows —
234             * arrives from $_POST as an int. Rejecting non-strings here would drop
235             * it silently after the screen had already offered a working-looking
236             * control for it. save_overrides() is what validates the name.
237             */
238            if ( ! is_string( $name ) && ! is_int( $name ) ) {
239                continue;
240            }
241
242            $name = (string) $name;
243
244            if ( ! is_string( $state ) ) {
245                continue;
246            }
247
248            if ( 'on' === $state ) {
249                $overrides[ $name ] = true;
250            } elseif ( 'off' === $state ) {
251                $overrides[ $name ] = false;
252            }
253        }
254
255        return $overrides;
256    }
257
258    /**
259     * Answer the feature flag package's resolution filter with this site's overrides.
260     *
261     * Deliberately not gated on is_a11n(): overrides are site-wide, so they have
262     * to apply to every visitor, including logged-out ones.
263     *
264     * @param bool   $enabled Whether the flag is enabled.
265     * @param string $name    Flag name.
266     * @return bool Whether the flag is enabled.
267     */
268    public static function filter_enabled( $enabled, $name ) {
269        $overrides = self::get_overrides();
270
271        if ( ! is_string( $name ) || ! array_key_exists( $name, $overrides ) ) {
272            return $enabled;
273        }
274
275        return $overrides[ $name ];
276    }
277
278    /**
279     * Register the Tools -> Feature Flags screen for Automatticians.
280     *
281     * @return string|false The resulting page's hook suffix, or false when the
282     *                      current user may not manage flag overrides.
283     */
284    public static function register_admin_page() {
285        if ( ! self::current_user_can_manage() ) {
286            return false;
287        }
288
289        $hook_suffix = add_submenu_page(
290            'tools.php',
291            'Feature Flags (a8c)',
292            'Feature Flags (a8c)',
293            self::CAPABILITY,
294            self::PAGE_SLUG,
295            array( self::class, 'render_admin_page' )
296        );
297
298        if ( $hook_suffix ) {
299            // Submissions are handled before any output, so the save can redirect.
300            add_action( 'load-' . $hook_suffix, array( self::class, 'handle_admin_post' ) );
301        }
302
303        return $hook_suffix;
304    }
305
306    /**
307     * Handle a submission and redirect back to the screen.
308     *
309     * Post/Redirect/Get: without the redirect, reloading the screen after a save
310     * re-submits the form, and back/forward navigation replays it.
311     *
312     * @return void
313     */
314    public static function handle_admin_post() {
315        $redirect = self::maybe_handle_submission();
316
317        if ( null === $redirect ) {
318            return;
319        }
320
321        wp_safe_redirect( $redirect );
322        exit;
323    }
324
325    /**
326     * Apply a submission, if this request is one, and say where to go next.
327     *
328     * Split from handle_admin_post() so everything except the redirect-and-exit
329     * is reachable from tests.
330     *
331     * @return string|null URL to redirect to, or null when this is not a submission.
332     */
333    public static function maybe_handle_submission() {
334        if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
335            return null;
336        }
337
338        /*
339         * Three outcomes, not two: a rejected submission has to be
340         * distinguishable from a plain page load. handle_save() returns false for
341         * a stale nonce — which this screen invites, being one you leave open —
342         * and re-rendering the unchanged page silently would look exactly like a
343         * successful save of the values already stored.
344         */
345        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- handle_save() verifies the nonce and sanitizes every value it stores.
346        $result = self::handle_save( wp_unslash( $_POST ) ) ? 'saved' : 'rejected';
347
348        return add_query_arg( 'flags-notice', $result, admin_url( 'tools.php?page=' . self::PAGE_SLUG ) );
349    }
350
351    /**
352     * Apply a submitted override form.
353     *
354     * Re-checks the Automattician gate, the capability, and the nonce: the
355     * screen's absence from the menu is not authorization on its own.
356     *
357     * The submitted map replaces the stored one wholesale, so two Automatticians
358     * saving the same site concurrently is last-write-wins. Every form carries
359     * every flag the screen listed, so in practice they overwrite each other with
360     * the same values; this is not an atomic read-modify-write and is not meant
361     * to be one.
362     *
363     * @param array $request Unslashed request data.
364     * @return bool Whether the overrides were saved.
365     */
366    public static function handle_save( array $request ) {
367        if ( ! self::current_user_can_manage() ) {
368            return false;
369        }
370
371        $nonce = isset( $request['_wpnonce'] ) && is_string( $request['_wpnonce'] ) ? $request['_wpnonce'] : '';
372
373        if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
374            return false;
375        }
376
377        $states = isset( $request['flag_state'] ) && is_array( $request['flag_state'] ) ? $request['flag_state'] : array();
378
379        self::save_overrides( self::overrides_from_states( $states ) );
380
381        return true;
382    }
383
384    /**
385     * Render the Tools -> Feature Flags screen.
386     *
387     * @return void
388     */
389    public static function render_admin_page() {
390        if ( ! self::current_user_can_manage() ) {
391            wp_die(
392                'Jetpack feature flag controls are Automatticians only. This is internal Automattic tooling, not a site feature.',
393                'Feature Flags',
394                array( 'response' => 403 )
395            );
396        }
397
398        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag set by our own redirect; nothing is written here.
399        $notice = isset( $_GET['flags-notice'] ) && is_string( $_GET['flags-notice'] )
400            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- As above.
401            ? sanitize_key( wp_unslash( $_GET['flags-notice'] ) )
402            : '';
403
404        self::print_screen( self::get_rows(), self::get_overrides(), $notice );
405    }
406
407    /**
408     * Print the screen's markup.
409     *
410     * @param array<string, array>   $rows      Flags to list, keyed by flag name.
411     * @param array<array-key, bool> $overrides The overrides currently in force.
412     * @param string                 $notice    'saved', 'rejected', or '' for a plain load.
413     * @return void
414     */
415    private static function print_screen( array $rows, array $overrides, $notice = '' ) {
416        $states = array(
417            'default' => 'Default',
418            'on'      => 'Force on',
419            'off'     => 'Force off',
420        );
421
422        ?>
423        <div class="wrap">
424            <h1>
425                Feature Flags
426                <span class="dashicons dashicons-lock" style="vertical-align: middle;" aria-hidden="true"></span>
427                <span style="font-size: 0.6em; font-weight: normal; vertical-align: middle;">Automatticians only</span>
428            </h1>
429
430            <?php if ( 'saved' === $notice ) : ?>
431                <div class="notice notice-success is-dismissible"><p>Overrides saved.</p></div>
432            <?php elseif ( 'rejected' === $notice ) : ?>
433                <div class="notice notice-error is-dismissible">
434                    <p>
435                        <strong>Your changes were not saved.</strong> The form&#8217;s security token had
436                        expired, or this session no longer passes the Automattician check. Reload the
437                        screen and try again — the states below are what is actually stored.
438                    </p>
439                </div>
440            <?php endif; ?>
441
442            <div class="notice notice-warning">
443                <p>
444                    <strong>Automatticians only — internal Automattic tooling.</strong> This screen is
445                    not part of WordPress.com, is not visible to the site&#8217;s owner, and is not
446                    supported. Do not point anyone outside Automattic at it.
447                </p>
448                <p>
449                    <strong>Overrides here are site-wide.</strong> They change what this site does for
450                    everyone — the site&#8217;s owner and logged-out visitors included — not just for
451                    you. Set a flag back to <em>Default</em> when you are done with it.
452                </p>
453            </div>
454
455            <p>
456                Flags are resolved as: the default they were registered with, then this screen, then
457                any <code>jetpack_feature_flag_enabled_{flag}</code> filter. That per-flag filter runs
458                last, so a sandbox patch or mu-plugin using it beats whatever you set here.
459            </p>
460
461            <form method="post" action="<?php echo esc_url( admin_url( 'tools.php?page=' . self::PAGE_SLUG ) ); ?>">
462                <?php wp_nonce_field( self::NONCE_ACTION ); ?>
463
464                <table class="widefat striped">
465                    <caption class="screen-reader-text">
466                        Feature flags registered on this site, with the override forced from this screen.
467                    </caption>
468                    <thead>
469                        <tr>
470                            <th scope="col">Flag</th>
471                            <th scope="col">Owner</th>
472                            <th scope="col">Default</th>
473                            <th scope="col">Effective</th>
474                            <th scope="col">State</th>
475                        </tr>
476                    </thead>
477                    <tbody>
478                        <?php if ( empty( $rows ) ) : ?>
479                            <tr>
480                                <td colspan="5">
481                                    No feature flags are registered on this site, and nothing is
482                                    overridden. Flags appear here once code running on this site calls
483                                    <code>Feature_Flags::register()</code>.
484                                </td>
485                            </tr>
486                        <?php endif; ?>
487
488                        <?php foreach ( $rows as $flag_name => $row ) : ?>
489                            <?php
490                            $current = 'default';
491                            if ( array_key_exists( $flag_name, $overrides ) ) {
492                                $current = $overrides[ $flag_name ] ? 'on' : 'off';
493                            }
494                            ?>
495                            <tr>
496                                <th scope="row">
497                                    <code><?php echo esc_html( $flag_name ); ?></code>
498                                    <?php if ( ! $row['registered'] ) : ?>
499                                        <p class="description">Not registered on this site.</p>
500                                    <?php elseif ( '' !== $row['description'] ) : ?>
501                                        <p class="description"><?php echo esc_html( $row['description'] ); ?></p>
502                                    <?php endif; ?>
503                                </th>
504                                <td><?php echo '' === $row['owner'] ? '&#8212;' : esc_html( $row['owner'] ); ?></td>
505                                <td>
506                                    <?php
507                                    if ( ! $row['registered'] ) {
508                                        echo '&#8212;';
509                                    } else {
510                                        echo $row['default'] ? 'On' : 'Off';
511                                    }
512                                    ?>
513                                </td>
514                                <td>
515                                    <?php
516                                    if ( null === $row['effective'] ) {
517                                        echo '&#8212;';
518                                    } else {
519                                        echo $row['effective'] ? 'On' : 'Off';
520                                    }
521                                    ?>
522                                </td>
523                                <td>
524                                    <?php if ( ! $row['overridable'] ) : ?>
525                                        <p class="description">
526                                            This flag&#8217;s name does not match
527                                            <code>^[a-z0-9][a-z0-9_-]*$</code>, so an override for it cannot
528                                            be stored. Fix the name where the flag is registered.
529                                        </p>
530                                    <?php else : ?>
531                                        <fieldset>
532                                            <legend class="screen-reader-text">Override state for <?php echo esc_html( $flag_name ); ?></legend>
533                                            <?php foreach ( $states as $value => $label ) : ?>
534                                                <label style="margin-inline-end: 1em;">
535                                                    <input
536                                                        type="radio"
537                                                        name="flag_state[<?php echo esc_attr( $flag_name ); ?>]"
538                                                        value="<?php echo esc_attr( $value ); ?>"
539                                                        <?php checked( $current, $value ); ?>
540                                                    />
541                                                    <?php echo esc_html( $label ); ?>
542                                                </label>
543                                            <?php endforeach; ?>
544                                        </fieldset>
545                                    <?php endif; ?>
546                                </td>
547                            </tr>
548                        <?php endforeach; ?>
549                    </tbody>
550                </table>
551
552                <?php submit_button( 'Save overrides' ); ?>
553            </form>
554        </div>
555        <?php
556    }
557
558    /**
559     * Whether this request has to be treated as a wpcomsh support session.
560     *
561     * Fails closed: anything short of a positive "not a support session" answer
562     * counts as one.
563     *
564     * The detector keeps its verdict in a client-side cookie and reports a
565     * missing cookie as "not a support session"
566     * (WPCOMSH_Support_Session_Detect::is_probably_support_session()). That
567     * default is right for the thing it was written for — suppressing a Tracks
568     * event — and wrong for an authorization gate, because the cookie is absent
569     * in cases nobody intended: it carries whatever lifetime wpcom passed as
570     * `expires`, so it can lapse while the login session lives on, and it is set
571     * SameSite=Strict, so a cross-site navigation into wp-admin does not send it
572     * on the first request. It can also simply be deleted; httponly stops page
573     * script from touching it, not a person with devtools open.
574     *
575     * So require has_detection_result() before trusting the verdict. The cost is
576     * that an Automattician whose browser holds no detection result does not see
577     * the screen until they log in through WordPress.com SSO again, which is what
578     * sets the cookie. Losing the screen for one navigation is the cheaper
579     * failure.
580     *
581     * Guarded with class_exists because the detector ships in wpcomsh, so it only
582     * exists on Atomic. Its absence counts as a support session for the same
583     * reason: with no detector there is no way to rule one out.
584     *
585     * @return bool Whether this request has to be treated as a support session.
586     */
587    private static function is_support_session() {
588        if ( ! class_exists( 'WPCOMSH_Support_Session_Detect' ) ) {
589            return true;
590        }
591
592        if ( ! WPCOMSH_Support_Session_Detect::has_detection_result() ) {
593            return true;
594        }
595
596        return WPCOMSH_Support_Session_Detect::is_probably_support_session();
597    }
598
599    /**
600     * Drop anything from an override map that could not have come from the form.
601     *
602     * The option is read on requests that have nothing to do with the screen, and
603     * flag names arrive as submitted form keys, so both directions are sanitized.
604     *
605     * @param array $overrides Untrusted override map.
606     * @return array<array-key, bool> Sanitized override map, sorted by flag name.
607     */
608    private static function sanitize_overrides( array $overrides ) {
609        $sanitized = array();
610
611        foreach ( $overrides as $name => $enabled ) {
612            // An all-digit flag name reaches this as an int key. See overrides_from_states().
613            if ( ! is_string( $name ) && ! is_int( $name ) ) {
614                continue;
615            }
616
617            if ( ! self::is_valid_flag_name( (string) $name ) ) {
618                continue;
619            }
620
621            if ( ! is_scalar( $enabled ) ) {
622                continue;
623            }
624
625            $sanitized[ $name ] = (bool) $enabled;
626        }
627
628        ksort( $sanitized );
629
630        return $sanitized;
631    }
632
633    /**
634     * Whether a string is shaped like a feature flag name.
635     *
636     * The same pattern the jetpack-feature-flags package documents and enforces
637     * at lint time with the Jetpack.FeatureFlags.FeatureFlagName sniff.
638     * Registration does not check it at runtime, so this screen has to.
639     *
640     * @param string $name Candidate flag name.
641     * @return bool Whether the name is valid.
642     */
643    private static function is_valid_flag_name( $name ) {
644        return (bool) preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $name );
645    }
646
647    /**
648     * Build the rows the screen lists.
649     *
650     * Registered flags come from the jetpack-feature-flags registry. Overrides
651     * for names the registry no longer knows are listed too — flags get retired,
652     * and an override that outlives its registration must stay visible on the
653     * screen that set it rather than becoming an invisible stuck value.
654     *
655     * @return array<string, array> Map of flag name to row data.
656     */
657    private static function get_rows() {
658        $has_registry = class_exists( Feature_Flags::class );
659        $registered   = $has_registry ? Feature_Flags::all() : array();
660        $rows         = array();
661
662        foreach ( $registered as $name => $definition ) {
663            $rows[ $name ] = array(
664                'registered'  => true,
665                'default'     => ! empty( $definition['default'] ),
666                'description' => isset( $definition['description'] ) ? (string) $definition['description'] : '',
667                'owner'       => isset( $definition['owner'] ) ? (string) $definition['owner'] : '',
668            );
669        }
670
671        foreach ( array_keys( self::get_overrides() ) as $name ) {
672            if ( isset( $rows[ $name ] ) ) {
673                continue;
674            }
675
676            $rows[ $name ] = array(
677                'registered'  => false,
678                'default'     => false,
679                'description' => '',
680                'owner'       => '',
681            );
682        }
683
684        foreach ( $rows as $name => $row ) {
685            /*
686             * Registration does not validate names at runtime — the package
687             * enforces the pattern with a PHPCS sniff, which never runs against
688             * the wpcom Simple codebase. sanitize_overrides() would silently drop
689             * an override for a name that fails it, so flag those rows here and
690             * render them without a control rather than offering one that does
691             * nothing.
692             */
693            $rows[ $name ]['overridable'] = self::is_valid_flag_name( $name );
694
695            /*
696             * What the flag actually resolves to right now, which is not always
697             * what this screen set: the per-flag
698             * `jetpack_feature_flag_enabled_{$name}` filter runs after ours and
699             * wins. Showing it turns the screen from a settings form into
700             * something that answers "I forced it on, so why is it still off?".
701             */
702            $rows[ $name ]['effective'] = $has_registry ? Feature_Flags::is_enabled( $name ) : null;
703        }
704
705        ksort( $rows );
706
707        return $rows;
708    }
709}