diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1fac82a..409ada3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,8 @@ jobs: token=$(printf '%s' "$fixture" | jq -r .token) docker run --rm --network php2shell-ci_fixture -v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/app -e CT_FIXTURE_TOKEN="$token" python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 python deployment/test-http-worker.py docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php cleanup + - name: Test closure, retention periods, holds and deletion tombstones + run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/retention.php - name: Test target control (challenge limits, manual review, blocked outcome) and its HTTP boundary run: | docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/control.php diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index e120775..f843d5c 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -479,7 +479,9 @@ table cannot make every public request re-run it). Schema 3 adds `profile_id`, `http_admitted` and `last_admit_ms` to the jobs table (existing rows default to `r2-header-v1` with empty counters); schema 4 adds `summary_sha256`, `bound_required` (the job's frozen evidence requirement; -existing rows default to `0`), `accepted_copy_status` and `accepted_copy_at`. Upgrades +existing rows default to `0`), `accepted_copy_status` and `accepted_copy_at`; schema 7 +adds the closure, retention, hold and identity columns and the tombstones table +(and re-checks `control_admitted`). Upgrades run on `init` and the worker API refuses requests until they complete. The healthcheck requires a successful authenticated exchange in the last ten @@ -515,7 +517,141 @@ site files, restore the scoped backups; existing accepted contracts and queue evidence must remain available. A started job with a missing receipt needs operator investigation, not a blind retry. -Retention follows the accepted policy: scan results twelve months, and request -and acceptance records three years. Maintenance must preserve current work and -must not delete unrelated WordPress content. Authentication replay records and -bounded controller logs have shorter operational lifetimes. +Retention follows the owner's decisions of 2026-10-22, implemented in +maintenance (which runs on worker polls that hand out no job and on the admin +queue page): + +- **Closure is explicit.** The first time a job is seen in a terminal state + (`completed`, `failed`, `failed_unknown`, `cancelled`, `expired`, `blocked`) + it is stamped `closed_at`; an accepted request that was never confirmed or + never showed target control closes when it expires. A job that can still run + (including one in `awaiting_control`, with a control check pending or leased) + is never closed or deleted for age, however old its dates say it is; if one is ever + reopened, its closure stamp is cleared. +- **Findings** (`result_json`, manual remainder notes): cleared twelve months + (366 days, so a leap day cannot make it early) after closure. The signed + evidence stays. +- **Signed authorization evidence** (the job row and its linked private request, + in whatever post status it is in): deleted at `retain_until`, set at closure + and afterwards only ever extended, never shortened: **six years after + closure**, except that r2 and earlier acceptances + (versions `YYYY-MM-DD-rN` up to `2026-10-21-r2`) keep their promised **three + years from acceptance**. Never less than three years, whatever the + configuration; a version that cannot be parsed gets the longer period. The + configured `evidence_retention_years` is floored at **six** for non-legacy + versions: a word, null, a boolean, 0 or a value below six means six, never + less. A value above 100 years (which would push the date past 2200, where no + date is trusted) is clamped to 100 years and logged once. +- **`retain_until` can be extended, never shortened.** When a job's stored date + comes due, it is compared with the date required today: the period derived + from the row under the current configuration, and never less than three years + after the job's creation. If the stored date is earlier (for example, + `evidence_retention_years` or `legacy_retention_years` was raised after + closure), it is extended to the required date (compare-and-set, under the + queue lock) and the job is deleted only when that later date arrives. Lowering + the configuration never moves a stored date earlier. A displayed date can + therefore grow when it comes due after a configuration change. +- **Legacy acceptance dated before its record.** A record is created within + moments of the acceptance it records. If an r2-or-earlier acceptance is dated + more than a day before the job's creation, the stated acceptance date and the + plugin's own creation stamp disagree. The job then gets three full years from + creation. That is later than the promised three years from acceptance, and it + is a definite date rather than "never". +- **Doubtful data is kept.** Deletion requires every date involved to be a real + `Y-m-d H:i:s` date no earlier than 2020 (zero dates and other restore debris + are refused), the stored deletion date to have arrived and to be no earlier + than the date required today (see above), and the job to be in a terminal + state. A job whose base date cannot be trusted keeps `retain_until` empty and + is shown as "unknown (kept)" in the admin queue. A record that appears to + have closed before it was created is kept. Nothing is deleted while the PHP + clock and the database clock (`UTC_TIMESTAMP()`) disagree by more than five + minutes, or while the server clock is earlier than a stored job timestamp (a + clock jumped backwards, a bad restore). The problem is logged once (not on + every run) until the clocks agree again; while it lasts no closure, deletion + date or examination time is stamped either (a wrong "now" is never recorded), + and only the reopening of non-terminal jobs proceeds. A forward jump of both clocks together cannot be detected. Each run + makes at most 50 deletion attempts in total, shared by the evidence step and + the tombstone clean-up. A job judged doubtful is looked at again at most daily + (`retention_checked_at`), so doubtful rows cannot hide due ones, and the queue + lock is held briefly. A job that has no opaque identity yet (created before it + existed and not yet reached by the backfill, which handles 100 jobs per run + from a stored cursor) is kept until it has one. The backfill adopts or writes + the request post's identity only when that post names this job + (`_ct_scan_job_id`); a post that names another job is left alone and the job + stays without identity, so it is kept. A single implausible far-future timestamp on any job + (later than now, before 2200) halts every deletion until it is corrected; this + is logged once, not on every run. +- **Narrow preservation hold** (admin queue): one job, a stated purpose of 20 to + 190 characters, a mandatory expiry of at most 730 days, recorded with who set + it and when (and who released it). While it stands nothing about that job is + cleared or deleted, including by a tombstone; after it expires or is released, + the job stays preserved for a further three days. Releasing ends the hold at + that moment (an already expired hold keeps its own end). The stated purpose is + kept, and who released it and when are recorded. A damaged hold (purpose + without an expiry) preserves rather than deletes. A hold that still protects + the job (unexpired or in its grace period) and has not been released is never + replaced: a second "place hold" (a stale tab, another administrator, a direct + POST) is refused and changes nothing, so it can neither shorten the hold nor + erase its purpose and setter. To change a hold, release it first (the job stays + protected through the grace period), then place the new one; a new hold never + ends earlier than the released one. The "Release hold" form carries the + identity of the hold it shows (purpose, expiry, setter, when set); a release + is a compare-and-set on those values, so a stale form can never end a newer + hold that replaced it ("hold changed: reload the queue"). +- **Retention runs outside the queue lock.** Only one retention run at a time + (its own advisory lock, throttled to about once a minute on worker polls that + hand out no job, so a lease is never delayed by it, and run again on the admin + queue page; a failure is logged and never breaks the poll or the page). The non-destructive steps are single + statements; every deletion takes the queue lock for that one job only, without + waiting (a busy queue means "next run"), re-reads the row under it and + re-checks every condition, so Stop (which waits up to three seconds for that + lock) is never queued behind a batch and a hold placed a moment ago is honoured. +- **Tombstones.** Deleting a job writes an identifier-only tombstone (job id, + request id, a keyed MAC of the scope, creation time, an opaque random identity, time and + reason; no personal or customer data) before the deletion. If a restored backup + brings the rows back, the next maintenance run deletes them again: a job row + matches on its ids, scope and creation time, and its request post is deleted + with it only if the post carries the tombstone's opaque identity (otherwise + the post id was recycled and belongs to another record); a restored request post matches + only on its opaque identity and the job id it names, and only when no job row currently uses that request + or job id (auto-increment ids can be recycled after a restore and must never + reach a live record). A restored job row that holds a lease (a leased or + running scan, or a control check in progress) is deleted again only after + that bounded lease ends, so the one-at-a-time gate is never freed while its + traffic may be in flight. A recognised job row is also deleted again only once + the retention deadline that applies to it today has passed (the later of its + stored `retain_until` and the date required under the current configuration): + a live row whose retention deletion wrote its tombstone and then failed + part-way cannot be told apart from a restored one, and a period raised since + must protect it. (A row without a derivable deadline cannot be such a live row, + because retention only deletes rows whose dates are all valid; the tombstone + then decides.) The identity comparison between the tombstone and the request's + post meta is binary, so differing table collations (for example + `utf8mb4_unicode_ci` post meta beside a `utf8mb4_unicode_520_ci` tombstones + table) cannot make it fail. A retention query that fails is never read as + "nothing to do": that step stops, the records are kept, and the failure is + logged once until a run completes cleanly. Restored rows that cannot be acted on (held or + leased) are skipped in the query, and the candidates are paged from where the + previous run stopped (wrapping around), so neither skipped rows nor rows whose + deletion keeps failing can hide later restored rows. Restored request posts are + paged the same way (by tombstone, from a stored cursor), and the job-row loop + leaves a fifth of the shared budget for them, so posts whose deletion is + refused cannot hide others either. Jobs created before the identity existed are given one + (adopting what their request already carries, if that request names the job) by the next maintenance run. Tombstones are kept 120 days, longer than the 90-day backup + rotation, then purged. The scope MAC is keyed with `wp_salt( 'secure_auth' )`: + rotating that salt (or `SECURE_AUTH_KEY`/`SECURE_AUTH_SALT`) stops existing + tombstones from recognising restored job rows (restored request posts are still + matched by their opaque identity). Normal retention still deletes restored rows + when their own deletion date is due. +- Deletion scrubs the contact fields first, then deletes the private request and + the job row; if any step fails the record stays (and is retried) rather than + being half-deleted. When its linked request exists, a due job is deleted only + if that request carries exactly the job's opaque identity; if the request has + another identity, or none, neither the request nor the job is touched (it is + re-examined daily and needs an administrator to repair the identity). + +Not covered here: backup rotation itself, the contract archive's historical Git +objects and clones (issue 11), unsigned caller leads (there is no lead record in +this plugin; the current form saves nothing without a signature), and deletion +from mail systems. Authentication replay records and bounded controller logs +have shorter operational lifetimes. diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 750ae3e..3811f8e 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -17,7 +17,9 @@ } if ( ! defined( 'CT_SCAN_SCHEMA_VERSION' ) ) { - define( 'CT_SCAN_SCHEMA_VERSION', '5' ); + // 7: the retention/hold/tombstone columns (introduced as 6) together with `control_admitted`, so an + // install that already recorded 6 before `control_admitted` existed re-runs the upgrade. + define( 'CT_SCAN_SCHEMA_VERSION', '7' ); } /** @@ -91,6 +93,12 @@ function ct_scan_config() { // (allow the customer's HTTP/HTTPS, deny private/metadata ranges) makes it a boundary. The // operator must state, deliberately, that this exists. See docs/WORDPRESS.md "Egress". 'egress_enforced' => false, + // Owner decisions (2026-10-22): findings 12 months; signed authorization evidence 6 years + // after request closure, except r2 and earlier acceptances, which keep 3 years from acceptance. + 'evidence_retention_years' => 6, + 'legacy_retention_years' => 3, + // How long a deletion tombstone is kept: longer than the longest backup rotation (90 days). + 'tombstone_retention_days' => 120, ); $path = defined( 'CT_SCAN_PRIVATE_CONFIG' ) @@ -185,6 +193,11 @@ function ct_scan_jobs_table() { return $wpdb->prefix . 'ct_scan_jobs'; } +function ct_scan_tombstones_table() { + global $wpdb; + return $wpdb->prefix . 'ct_scan_tombstones'; +} + function ct_scan_nonces_table() { global $wpdb; return $wpdb->prefix . 'ct_scan_nonces'; @@ -263,15 +276,42 @@ function ct_scan_maybe_install_schema() { control_verified_at datetime NULL, control_valid_until datetime NULL, control_evidence text NULL, + closed_at datetime NULL, + retain_until datetime NULL, + hold_reason varchar(190) NULL, + hold_until datetime NULL, + hold_set_by bigint(20) unsigned NULL, + hold_set_at datetime NULL, + hold_released_by bigint(20) unsigned NULL, + hold_released_at datetime NULL, + identity char(32) NULL, + retention_checked_at datetime NULL, PRIMARY KEY (job_id), UNIQUE KEY request_id (request_id), KEY status_created (status,created_at), KEY scope_created (scope_hash,created_at), KEY authorization_expires_at (authorization_expires_at), KEY confirmation_token_hash (confirmation_token_hash), - KEY stop_token_hash (stop_token_hash) + KEY stop_token_hash (stop_token_hash), + KEY retain_until (retain_until) ) {$charset};"; dbDelta( $sql ); + // Deletion tombstones: identifiers only, no personal data. A restored backup cannot resurrect a + // deleted record because maintenance deletes anything that matches a tombstone again. + $tombstones = ct_scan_tombstones_table(); + dbDelta( "CREATE TABLE {$tombstones} ( + tombstone_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + job_id bigint(20) unsigned NOT NULL, + request_id bigint(20) unsigned NOT NULL, + scope_mac char(64) NOT NULL, + created_at datetime NOT NULL, + deleted_at datetime NOT NULL, + reason varchar(32) NOT NULL, + identity char(32) NULL, + PRIMARY KEY (tombstone_id), + UNIQUE KEY identity (job_id,request_id,scope_mac,created_at), + KEY deleted_at (deleted_at) + ) {$charset};" ); $sql = "CREATE TABLE {$nonces} ( nonce_hash char(64) NOT NULL, expires_at datetime NOT NULL, @@ -280,8 +320,9 @@ function ct_scan_maybe_install_schema() { ) {$charset};"; dbDelta( $sql ); $required = array( - $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_admitted', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence' ), + $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_admitted', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence', 'closed_at', 'retain_until', 'hold_reason', 'hold_until', 'hold_set_by', 'hold_set_at', 'hold_released_by', 'hold_released_at', 'identity', 'retention_checked_at' ), $nonces => array( 'nonce_hash', 'expires_at' ), + $tombstones => array( 'tombstone_id', 'job_id', 'request_id', 'scope_mac', 'created_at', 'deleted_at', 'reason', 'identity' ), ); foreach ( $required as $table => $columns ) { $found = $wpdb->get_col( "SHOW COLUMNS FROM {$table}", 0 ); @@ -629,6 +670,9 @@ function ct_scan_on_request_ready( $request_id, $fields ) { if ( $existing ) { update_post_meta( $request_id, '_ct_scan_event_recorded', '1' ); update_post_meta( $request_id, '_ct_scan_job_id', (int) $existing->job_id ); + if ( ! empty( $existing->identity ) ) { + update_post_meta( $request_id, '_ct_scan_identity', $existing->identity ); + } update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $existing->authorization_expires_at ); ct_scan_mark_request_status( $request_id, $existing->status, $existing->error_code ? $existing->error_code : '' ); return array( 'job_id' => (int) $existing->job_id, 'status' => $existing->status, 'confirmation_email_status' => $existing->confirmation_email_status, 'duplicate' => true ); @@ -650,6 +694,7 @@ function ct_scan_on_request_ready( $request_id, $fields ) { if ( $target_count >= min( 10, max( 1, absint( $config['target_daily_limit'] ) ) ) ) { return ct_scan_safe_notice( $request_id, 'target_quota' ); } + $identity = bin2hex( random_bytes( 16 ) ); $inserted = $wpdb->insert( $jobs, array( @@ -671,18 +716,24 @@ function ct_scan_on_request_ready( $request_id, $fields ) { 'profile_id' => ct_scan_current_profile(), 'summary_sha256' => $accepted['summary_sha256'], 'bound_required' => $accepted['bound_required'] ? 1 : 0, + // Opaque identity mirrored on the private request: lets a deletion tombstone recognise + // the same record after a restore without matching on recyclable numeric ids. + 'identity' => $identity, // Frozen at creation: a later configuration change cannot relax an existing job. Every version is strict // (requires control of the exact target) unless an r2-or-earlier version was explicitly grandfathered. // A profile broader than the r2 header screening can never run without target control. 'control_required' => ( 'strict' === ct_scan_terms_policy( $accepted['terms_version'] ) || 'r2-header-v1' !== ct_scan_current_profile() ) ? 1 : 0, ), - array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d' ) + array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ) ); if ( 1 !== $inserted ) { $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE request_id = %d LIMIT 1", $request_id ) ); if ( $existing ) { update_post_meta( $request_id, '_ct_scan_event_recorded', '1' ); update_post_meta( $request_id, '_ct_scan_job_id', (int) $existing->job_id ); + if ( ! empty( $existing->identity ) ) { + update_post_meta( $request_id, '_ct_scan_identity', $existing->identity ); + } update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $existing->authorization_expires_at ); ct_scan_mark_request_status( $request_id, $existing->status, $existing->error_code ? $existing->error_code : '' ); return array( 'job_id' => (int) $existing->job_id, 'status' => $existing->status, 'confirmation_email_status' => $existing->confirmation_email_status, 'duplicate' => true ); @@ -692,6 +743,7 @@ function ct_scan_on_request_ready( $request_id, $fields ) { $job_id = (int) $wpdb->insert_id; update_post_meta( $request_id, '_ct_scan_event_recorded', '1' ); update_post_meta( $request_id, '_ct_scan_job_id', $job_id ); + update_post_meta( $request_id, '_ct_scan_identity', $identity ); update_post_meta( $request_id, '_ct_scan_authorization_expires_at', $accepted['authorization_expires'] ); ct_scan_mark_request_status( $request_id, 'awaiting_confirmation' ); } finally { @@ -821,38 +873,789 @@ function ct_scan_maintenance_locked() { $nonce_table = ct_scan_nonces_table(); $wpdb->query( $wpdb->prepare( "DELETE FROM {$nonce_table} WHERE expires_at < %s", gmdate( 'Y-m-d H:i:s', time() - 600 ) ) ); - $result_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-12 months' )->format( 'Y-m-d H:i:s' ); - $wpdb->query( $wpdb->prepare( - "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND finished_at IS NOT NULL AND finished_at < %s", - $result_cutoff +} + +/** Statuses from which a job never runs again; reaching one is the request's closure. */ +function ct_scan_terminal_statuses() { + return array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ); +} + +/** Does this job hold a lease for target traffic: a leased or running scan, or a control check being made? */ +function ct_scan_job_holds_lease( $row ) { + return in_array( $row->status, array( 'leased', 'running' ), true ) + || ( 'awaiting_control' === $row->status && 'checking' === $row->control_state ); +} + +function ct_scan_terminal_sql() { + return "'" . implode( "','", ct_scan_terminal_statuses() ) . "'"; +} + +/** + * A stored UTC datetime we are willing to compute a deletion date from: the exact `Y-m-d H:i:s` + * shape, a real calendar date, and not earlier than 2020 (zero dates, which PHP silently parses + * into year -1, and other restore/migration debris are refused rather than guessed at). + */ +function ct_scan_valid_datetime( $value ) { + if ( ! is_string( $value ) || ! preg_match( '/\A(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\z/', $value, $m ) || (int) $m[1] < 2020 || (int) $m[1] > 2200 ) { + return false; + } + if ( ! checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) || (int) $m[4] > 23 || (int) $m[5] > 59 || (int) $m[6] > 59 ) { + return false; + } + return true; +} + +/** The longest retention period honoured, so every derived date stays a valid (pre-2200) date. */ +function ct_scan_max_retention_years() { + return 100; +} + +/** + * A retention period in whole years, never below its floor: 3 for r2-and-earlier acceptances, 6 for + * everything else. A value that is not a plain positive number (a word, null, a boolean, an array, 0, + * negative) means "the floor", never "shorter", and fractions round down but never below the floor. + * A value above ct_scan_max_retention_years() (which would push the deletion date past 2200, where no + * date is trusted and the record would be kept forever) is clamped to that maximum and logged once. + */ +function ct_scan_clamp_years( $years, $floor = 3 ) { + static $warned = false; + $floor = max( 3, (int) $floor ); + if ( ( ! is_int( $years ) && ! is_float( $years ) && ! ( is_string( $years ) && is_numeric( $years ) ) ) || ! is_finite( (float) $years ) ) { + return $floor; + } + if ( (float) $years > ct_scan_max_retention_years() ) { + if ( ! $warned ) { + $warned = true; + error_log( 'ct-scan: a configured retention period above ' . ct_scan_max_retention_years() . ' years was clamped to ' . ct_scan_max_retention_years() . ' years.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + return max( $floor, ct_scan_max_retention_years() ); + } + return max( $floor, (int) $years ); +} + + +/** + * When the signed authorization evidence of a job may be deleted: r2 and earlier acceptances three + * years from acceptance; every other version six years after the request's closure (owner decision, + * 2026-10-22). Returns false, and the job is then never deleted, if the base date is not trustworthy. + * Month/year arithmetic that overflows (Feb 29) lands later, never earlier. + */ +function ct_scan_retain_until( $row ) { + $config = ct_scan_config(); + if ( ct_scan_is_legacy_version( $row->terms_version ) ) { + $years = ct_scan_clamp_years( $config['legacy_retention_years'], 3 ); + $base = $row->terms_accepted_at; + } else { + $years = ct_scan_clamp_years( $config['evidence_retention_years'], 6 ); + $base = $row->closed_at; + } + if ( ! ct_scan_valid_datetime( $base ) ) { + return false; + } + try { + return ( new DateTimeImmutable( $base, new DateTimeZone( 'UTC' ) ) )->modify( '+' . $years . ' years' )->format( 'Y-m-d H:i:s' ); + } catch ( Throwable $error ) { + return false; + } +} + +/** The grace period after a hold expires before its job becomes deletable (nothing vanishes the minute a hold lapses). */ +function ct_scan_hold_grace_days() { + return 3; +} + +/** + * Is this job under a preservation hold? An unexpired hold preserves, and so does one that expired + * less than the grace period ago; a hold with a purpose but no expiry (not creatable through the admin + * action) also preserves, so a damaged hold never causes a deletion. + */ +function ct_scan_job_on_hold( $row, $now ) { + if ( empty( $row->hold_reason ) ) { + return false; + } + if ( empty( $row->hold_until ) || ! ct_scan_valid_datetime( $row->hold_until ) ) { + return true; + } + $grace_end = ( new DateTimeImmutable( $row->hold_until, new DateTimeZone( 'UTC' ) ) )->modify( '+' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); + return $grace_end > $now; +} + +/** + * The earliest date this job's evidence may be deleted, judged from the row today: the period derived from + * the row under today's configuration (so a longer period configured after closure is honoured), and never + * less than three years after the record's creation. False if a base date cannot be trusted. + * + * A record is created within moments of the acceptance it records, so a derived date within a day of + * "creation + 3 years" is accepted as it is. A derived date more than a day short of that (in practice: an + * r2-or-earlier acceptance dated more than a day before its record was created, the stated acceptance date + * and the plugin's own creation stamp disagreeing) is replaced by three full years from creation. That date + * is later than the promised three years from acceptance, and it is a definite date rather than "never". + */ +function ct_scan_required_retain_until( $row ) { + $derived = ct_scan_retain_until( $row ); + if ( false === $derived || ! ct_scan_valid_datetime( $row->created_at ) ) { + return false; + } + try { + $created = new DateTimeImmutable( $row->created_at, new DateTimeZone( 'UTC' ) ); + if ( $derived >= $created->modify( '+3 years -1 day' )->format( 'Y-m-d H:i:s' ) ) { + return $derived; + } + return max( $derived, $created->modify( '+3 years' )->format( 'Y-m-d H:i:s' ) ); + } catch ( Throwable $error ) { + return false; + } +} + +/** + * What retention may do with this job now: array( 'keep' | 'extend' | 'delete', $date ). Every date involved + * must be trustworthy, the job terminal and not held, or it is kept. A stored deletion date earlier than the + * one required today (ct_scan_required_retain_until()) is never honoured: it is EXTENDED to the required date + * ($date), and the job is deleted only when that later date arrives. A stored date is never shortened. + */ +function ct_scan_deletion_decision( $row, $now ) { + if ( ! in_array( $row->status, ct_scan_terminal_statuses(), true ) || ct_scan_job_on_hold( $row, $now ) ) { + return array( 'keep', null ); + } + // A job without its opaque identity (not yet reached by the identity backfill) is kept until it has one: + // deleting it would leave a tombstone that cannot recognise its restored request. + if ( ! isset( $row->identity ) || ! is_string( $row->identity ) || '' === $row->identity ) { + return array( 'keep', null ); + } + foreach ( array( $row->created_at, $row->closed_at, $row->retain_until ) as $date ) { + if ( ! ct_scan_valid_datetime( $date ) ) { + return array( 'keep', null ); + } + } + // A record cannot have closed before it was created; if it appears to, a date is wrong. + if ( $row->closed_at < $row->created_at ) { + return array( 'keep', null ); + } + $required = ct_scan_required_retain_until( $row ); + if ( false === $required ) { + return array( 'keep', null ); + } + if ( $row->retain_until < $required ) { + return array( 'extend', $required ); + } + return array( $row->retain_until <= $now ? 'delete' : 'keep', null ); +} + +/** May this job's records be deleted now? Only when ct_scan_deletion_decision() says so; anything doubtful is kept. */ +function ct_scan_deletion_allowed( $row, $now ) { + return 'delete' === ct_scan_deletion_decision( $row, $now )[0]; +} + +/** + * May the tombstone loop delete this job row (one that a tombstone recognises) now, as far as its retention + * deadline is concerned? The loop cannot tell a row restored from a backup from a live row whose retention + * deletion wrote its tombstone and then failed part-way, so it never deletes a row before the deadline that + * retention itself would apply today: the later of the stored retain_until (never shortened) and the date + * required under today's configuration (ct_scan_required_retain_until()). A longer period configured after a + * failed deletion therefore protects that row exactly as it protects any other. + * + * When that date cannot be derived (no valid closure for a current-version acceptance, a damaged creation + * date), the row cannot be such a live row: retention deleted only rows whose dates were all valid, and a + * failed deletion leaves those dates as they were. The row is then a restored copy from before its closure + * (or a damaged one) and the tombstone, which records that this record was already deleted, decides. + */ +function ct_scan_tombstone_deadline_passed( $row, $now ) { + $required = ct_scan_required_retain_until( $row ); + if ( false === $required ) { + return true; + } + $deadline = $required; + if ( isset( $row->retain_until ) && ct_scan_valid_datetime( $row->retain_until ) && $row->retain_until > $deadline ) { + $deadline = $row->retain_until; + } + return $deadline <= $now; +} + +/** + * Did the query just run fail? Retention never mistakes a failed query for "nothing to do" (or, worse, for + * "no live record uses this id"): the caller stops that step and keeps the records. Logged once until a + * retention run completes without a failed query. + */ +function ct_scan_retention_query_failed( $step ) { + global $wpdb; + if ( '' === (string) $wpdb->last_error ) { + return false; + } + $GLOBALS['ct_scan_retention_query_failure'] = true; + if ( ! get_option( 'ct_scan_retention_query_warned' ) ) { + error_log( 'ct-scan: retention step "' . $step . '" stopped: a database query failed; the records are kept and the step is retried on a later run. Logged once until a run completes without a failed query.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + update_option( 'ct_scan_retention_query_warned', $step, false ); + } + return true; +} + +/** + * A tombstone must not let anyone who reads it (a backup, a database export) test whether a given website + * was ever a customer: the scope is stored as a keyed MAC, not as the plain hash of the hostname. + */ +function ct_scan_tombstone_mac( $scope_hash ) { + return hash_hmac( 'sha256', (string) $scope_hash, wp_salt( 'secure_auth' ) . '|ct-scan-tombstone-v1' ); +} + +/** Remember a deleted record by identifier only, so a restored backup cannot bring it back. */ +function ct_scan_record_tombstone( $row, $reason ) { + global $wpdb; + $result = $wpdb->query( $wpdb->prepare( + 'INSERT IGNORE INTO ' . ct_scan_tombstones_table() . ' (job_id, request_id, scope_mac, created_at, deleted_at, reason, identity) VALUES (%d, %d, %s, %s, %s, %s, %s)', + (int) $row->job_id, + (int) $row->request_id, + ct_scan_tombstone_mac( $row->scope_hash ), + $row->created_at, + ct_scan_utc_now(), + substr( sanitize_key( $reason ), 0, 32 ), + is_string( $row->identity ) ? $row->identity : '' ) ); - $request_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years' )->format( 'Y-m-d H:i:s' ); - $old_rows = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id FROM {$table} WHERE terms_accepted_at < %s AND status IN ('completed','failed','failed_unknown','cancelled','expired','blocked')", $request_cutoff ) ); - foreach ( (array) $old_rows as $row ) { + return false !== $result; +} + +/** + * Delete one job and its linked request, whatever status that post is in. True only if the job row is gone + * and a tombstone exists. + * + * The request post is deleted only when it provably belongs to this job (post ids can be recycled after a + * restore). For a retention deletion, a post that does not carry exactly the job's (non-empty) opaque + * identity keeps everything (nothing is deleted; retried later). For a restored job row recognised by its tombstone + * ($tombstone_identity given), the post is deleted only if it carries exactly that identity; otherwise the + * post is another record and is left alone while the restored row itself is deleted again. + */ +function ct_scan_delete_job_records( $row, $reason, $tombstone_identity = null ) { + global $wpdb; + $table = ct_scan_jobs_table(); + clean_post_cache( (int) $row->request_id ); + $post = get_post( (int) $row->request_id ); + $ours = $post && 'ct_promo_request' === $post->post_type && (int) $post->ID === (int) $row->request_id; + if ( $ours ) { + $post_identity = ct_scan_meta_string( (int) $post->ID, '_ct_scan_identity' ); + if ( null !== $tombstone_identity ) { + $ours = is_string( $tombstone_identity ) && '' !== $tombstone_identity && hash_equals( $tombstone_identity, $post_identity ); + } elseif ( ! is_string( $row->identity ) || '' === $row->identity || ! hash_equals( $row->identity, $post_identity ) ) { + // Only an exact, non-empty identity match proves the post is this job's (an empty or different + // identity may be a recycled id): keep everything and retry later. + return false; + } + } + if ( ! ct_scan_record_tombstone( $row, $reason ) ) { + return false; + } + // Scrub personal fields first so a failure part-way never leaves contact data behind a deleted link. + $personal_scrubbed = $wpdb->update( $table, array( 'contact_email' => '', 'contact_name' => '' ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s' ), array( '%d' ) ); + if ( false === $personal_scrubbed ) { + return false; + } + if ( 0 === $personal_scrubbed ) { + $scrubbed = $wpdb->get_row( $wpdb->prepare( "SELECT contact_email,contact_name FROM {$table} WHERE job_id = %d", (int) $row->job_id ) ); + if ( ! $scrubbed || '' !== $scrubbed->contact_email || '' !== $scrubbed->contact_name ) { + return false; + } + } + // The request carries the contact details and the full contract copy: delete it in any status + // (a trashed or re-published copy must not outlive the job that pointed to it). + if ( $ours ) { + if ( ! wp_delete_post( (int) $row->request_id, true ) ) { + return false; + } + } + return false !== $wpdb->delete( $table, array( 'job_id' => (int) $row->job_id ), array( '%d' ) ); +} + +/** + * Retention is NOT done under the queue lock. Only one retention run at a time (its own advisory lock); + * the non-destructive steps are single statements; every deletion takes the queue lock for that one job + * only (no waiting: a busy queue means "try again next run"), re-reads the row under it and re-checks + * every condition, so Stop, claims and admissions are never delayed for more than one job's deletion + * and a hold placed a moment ago is honoured. + */ +function ct_scan_retention_lock_name() { + global $wpdb; + return 'ctscanret_' . substr( hash( 'sha256', DB_NAME . '|' . $wpdb->prefix ), 0, 22 ); +} + +function ct_scan_retention_lock_acquire() { + global $wpdb; + return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', ct_scan_retention_lock_name() ) ); +} + +function ct_scan_retention_lock_release() { + global $wpdb; + $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', ct_scan_retention_lock_name() ) ); +} + +/** Run retention now (`$force`) or at most about once a minute. Returns true if a run happened. */ +function ct_scan_retention_run( $force = false ) { + if ( ! $force && get_transient( 'ct_scan_retention_last' ) ) { + return false; + } + if ( ! ct_scan_retention_lock_acquire() ) { + return false; + } + try { + set_transient( 'ct_scan_retention_last', '1', 55 ); + ct_scan_retention_steps( ct_scan_utc_now() ); + } finally { + ct_scan_retention_lock_release(); + } + return true; +} + +/** Retention from a request path (worker poll, admin page): a failure keeps the records and is logged, never propagated. */ +function ct_scan_retention_run_safely() { + try { + return ct_scan_retention_run(); + } catch ( Throwable $error ) { + error_log( 'ct-scan: a retention run failed (' . get_class( $error ) . '); records are kept and the run is retried later.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return false; + } +} + +/** At most this many deletion attempts per retention run, shared by every deleting step. */ +function ct_scan_retention_batch_limit() { + return 50; +} + +/** The largest disagreement tolerated between the PHP clock and the database clock before deletions stop. */ +function ct_scan_clock_skew_limit_seconds() { + return 300; +} + +/** + * Deletion is refused when the server clock looks wrong: if the PHP clock and the database clock disagree + * by more than five minutes (one of them jumped, forwards or backwards), or if any job carries a (plausible) + * timestamp later than "now" (a clock that jumped backwards, a bad restore). A forward jump of both clocks + * together cannot be detected here. + */ +function ct_scan_clock_is_sane( $now ) { + global $wpdb; + $db_now = $wpdb->get_var( 'SELECT UTC_TIMESTAMP()' ); + if ( ! is_string( $db_now ) || ! ct_scan_valid_datetime( $db_now ) || abs( strtotime( $db_now . ' UTC' ) - time() ) > ct_scan_clock_skew_limit_seconds() ) { + return false; + } + $table = ct_scan_jobs_table(); + $limit = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '+5 minutes' )->format( 'Y-m-d H:i:s' ); + foreach ( array( 'created_at', 'finished_at', 'closed_at', 'terms_accepted_at' ) as $column ) { + $newest = $wpdb->get_var( "SELECT MAX({$column}) FROM {$table} WHERE {$column} < '2200-01-01 00:00:00'" ); + if ( ct_scan_retention_query_failed( 'clock' ) || ( is_string( $newest ) && $newest > $limit ) ) { + return false; + } + } + return $now >= '2020-01-01 00:00:00' && $now < '2200-01-01 00:00:00'; +} + +/** Log a clock that stops deletions once per occurrence (not on every run); forget it once the clock is sane again. */ +function ct_scan_note_clock_state( $sane ) { + $flag = 'ct_scan_retention_clock_warned'; + if ( ! $sane && ! get_option( $flag ) ) { + error_log( 'ct-scan: retention deletions skipped: the server clock disagrees with the database clock or is earlier than a stored job timestamp. Logged once until the clock is consistent again.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + update_option( $flag, ct_scan_utc_now(), false ); + } elseif ( $sane && get_option( $flag ) ) { + delete_option( $flag ); + } +} + +/** + * Act on one job if, under the queue lock and from a fresh read, retention may: delete it, or extend a + * stored deletion date that is earlier than the one required today (compare-and-set, never shorter). + * Returns 'deleted'|'extended'|'kept'|'failed'|'busy'. + */ +function ct_scan_delete_if_due( $job_id, $now ) { + global $wpdb; + if ( ! ct_scan_lock_acquire() ) { + return 'busy'; + } + try { + $row = ct_scan_row( $job_id ); + if ( ! $row ) { + return 'kept'; + } + list( $action, $until ) = ct_scan_deletion_decision( $row, $now ); + if ( 'extend' === $action ) { + $updated = $wpdb->update( ct_scan_jobs_table(), array( 'retain_until' => $until, 'retention_checked_at' => null ), array( 'job_id' => (int) $row->job_id, 'retain_until' => $row->retain_until ), array( '%s', '%s' ), array( '%d', '%s' ) ); + return 1 === $updated ? 'extended' : 'kept'; + } + if ( 'delete' !== $action ) { + return 'kept'; + } + return ct_scan_delete_job_records( $row, 'retention' ) ? 'deleted' : 'failed'; + } finally { + ct_scan_lock_release(); + } +} + +function ct_scan_retention_steps( $now ) { + global $wpdb; + $table = ct_scan_jobs_table(); + $terminal = ct_scan_terminal_sql(); + $deadline = microtime( true ) + 3.0; + $GLOBALS['ct_scan_retention_query_failure'] = false; + // 0. A job that can run again is not closed: forget any closure stamped on it. (Clears dates only: keeps data.) + // If this fails, nothing below may run: a job that can run again could still carry an old deletion date. + $wpdb->query( "UPDATE {$table} SET closed_at = NULL, retain_until = NULL WHERE status NOT IN ({$terminal}) AND (closed_at IS NOT NULL OR retain_until IS NOT NULL)" ); + if ( ct_scan_retention_query_failed( 'reopen' ) ) { + return; + } + // Everything below stamps "now" or deletes. Refuse all of it if the clock cannot be trusted, so a wrong + // clock's "now" is never stamped as a closure, a deletion date or an examination time. + $sane = ct_scan_clock_is_sane( $now ); + ct_scan_note_clock_state( $sane ); + if ( ! $sane ) { + return; + } + // 1. Closure is explicit: the first time a job is seen terminal it is stamped. + $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET closed_at = COALESCE(finished_at, %s) WHERE closed_at IS NULL AND status IN ({$terminal})", $now ) ); + ct_scan_retention_query_failed( 'closure' ); // only stamps dates; the steps below re-read what they act on + // A row judged doubtful is re-examined at most daily, so doubtful rows can never starve the rest. + $recheck = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-1 day' )->format( 'Y-m-d H:i:s' ); + // 1b. Jobs that predate the opaque identity get one (and so does their request) so a restored request + // can be recognised after deletion. The request's identity is adopted or written only when that post + // provably belongs to this job (its _ct_scan_job_id is this job's id); a request post that names another + // job (a recycled id) is left alone, and so is the job, which then stays without identity and is kept. + // Paged from a stored cursor (wrapping around) so such rows can never starve later ones. + $identity_cursor = max( 0, (int) get_option( 'ct_scan_identity_cursor', 0 ) ); + $anonymous = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id,identity FROM {$table} WHERE (identity IS NULL OR identity = '') AND job_id > %d ORDER BY job_id ASC LIMIT 100", $identity_cursor ) ); + $anonymous_failed = ct_scan_retention_query_failed( 'identity' ); + foreach ( $anonymous_failed ? array() : (array) $anonymous as $row ) { + $identity_cursor = (int) $row->job_id; + clean_post_cache( (int) $row->request_id ); $post = get_post( (int) $row->request_id ); - $personal_scrubbed = $wpdb->update( - $table, - array( 'contact_email' => '', 'contact_name' => '' ), - array( 'job_id' => (int) $row->job_id ), - array( '%s', '%s' ), - array( '%d' ) - ); - if ( false === $personal_scrubbed ) { + if ( $post && 'ct_promo_request' === $post->post_type ) { + if ( (string) (int) $row->job_id !== ct_scan_meta_string( (int) $row->request_id, '_ct_scan_job_id' ) ) { + continue; // The post names another job (or none): touch neither; the job is kept. + } + // Adopt an identity the request already carries; otherwise mint one for both. + $carried = ct_scan_meta_string( (int) $row->request_id, '_ct_scan_identity' ); + $identity = 1 === preg_match( '/\A[a-f0-9]{32}\z/', $carried ) ? $carried : bin2hex( random_bytes( 16 ) ); + if ( 1 === $wpdb->update( $table, array( 'identity' => $identity ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ) && $identity !== $carried ) { + update_post_meta( (int) $row->request_id, '_ct_scan_identity', $identity ); + } + } else { + // No request post of ours under that id: the job gets an identity of its own; no post is touched + // (ct_scan_delete_job_records() never deletes a post that is not this job's request). + $wpdb->update( $table, array( 'identity' => bin2hex( random_bytes( 16 ) ) ), array( 'job_id' => (int) $row->job_id, 'identity' => $row->identity ), array( '%s' ), array( '%d', '%s' ) ); + } + } + if ( ! $anonymous_failed ) { + update_option( 'ct_scan_identity_cursor', count( (array) $anonymous ) < 100 ? '0' : (string) $identity_cursor, false ); + } + // 2. The evidence deletion date is set at closure; later it may only be extended (step 4), never shortened. + // An untrustworthy base leaves it NULL (kept). + $unset = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,terms_version,terms_accepted_at,closed_at FROM {$table} WHERE closed_at IS NOT NULL AND retain_until IS NULL AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY job_id ASC LIMIT 200", $recheck ) ); + foreach ( ct_scan_retention_query_failed( 'retain_until' ) ? array() : (array) $unset as $row ) { + $when = ct_scan_retain_until( $row ); + if ( false !== $when ) { + $wpdb->update( $table, array( 'retain_until' => $when ), array( 'job_id' => (int) $row->job_id, 'retain_until' => null ), array( '%s' ), array( '%d', '%s' ) ); + } else { + $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); + } + } + // 3. Findings: twelve months after closure (366 days, so a leap day cannot make it early), unless held. + $findings_cutoff = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-366 days' )->format( 'Y-m-d H:i:s' ); + $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); + $wpdb->query( $wpdb->prepare( + "UPDATE {$table} SET result_json = NULL, manual_remainder_notes = NULL WHERE (result_json IS NOT NULL OR manual_remainder_notes IS NOT NULL) AND closed_at IS NOT NULL AND closed_at >= '2020-01-01 00:00:00' AND closed_at < %s AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s))", + $findings_cutoff, + $grace_edge + ) ); + ct_scan_retention_query_failed( 'findings' ); // a failure only keeps the findings longer + // 4. Evidence: delete what has outlived its retain_until (extending a date that is shorter than today's + // rules require), except under a hold. One budget of deletion attempts for the whole run (steps 4 and 5). + $deletions_left = ct_scan_retention_batch_limit(); + $due = $wpdb->get_results( $wpdb->prepare( + "SELECT job_id FROM {$table} WHERE retain_until IS NOT NULL AND retain_until <= %s AND closed_at IS NOT NULL AND status IN ({$terminal}) AND identity IS NOT NULL AND identity <> '' AND (hold_reason IS NULL OR (hold_until >= '2020-01-01 00:00:00' AND hold_until <= %s)) AND (retention_checked_at IS NULL OR retention_checked_at < %s) ORDER BY retain_until ASC LIMIT 50", + $now, + $grace_edge, + $recheck + ) ); + foreach ( ct_scan_retention_query_failed( 'evidence' ) ? array() : (array) $due as $row ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline ) { + break; // enough for one run: continue next time + } + $outcome = ct_scan_delete_if_due( (int) $row->job_id, $now ); + if ( 'busy' === $outcome ) { + break; // the queue is in use: continue next time + } + if ( 'deleted' === $outcome || 'failed' === $outcome ) { + --$deletions_left; + } + if ( 'kept' === $outcome || 'failed' === $outcome ) { + // Doubtful or failed: keep it, and look at it again tomorrow rather than on every run. + $wpdb->update( $table, array( 'retention_checked_at' => $now ), array( 'job_id' => (int) $row->job_id ), array( '%s' ), array( '%d' ) ); + } + } + // 5. A restored backup must not resurrect deleted records: delete matches again (same budget). + ct_scan_apply_tombstones( $now, $deadline, $deletions_left ); + $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE deleted_at < %s', ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . max( 90, (int) ct_scan_config()['tombstone_retention_days'] ) . ' days' )->format( 'Y-m-d H:i:s' ) ) ); + ct_scan_retention_query_failed( 'tombstone_expiry' ); // a failure only keeps tombstones longer + if ( empty( $GLOBALS['ct_scan_retention_query_failure'] ) && get_option( 'ct_scan_retention_query_warned' ) ) { + delete_option( 'ct_scan_retention_query_warned' ); + } +} + +/** + * Delete again any job row (and linked request) that a deletion tombstone recognises after a restore. + * A job row matches on its numeric identifiers AND creation time AND scope; its request post is deleted with + * it only if the post carries the tombstone's opaque identity (a recycled post id is another record). A + * request post whose job row was not restored matches only on the opaque identity recorded at creation, and + * only when no job row currently uses that request or job id (recycled auto-increment ids must never reach a + * live record). Holds are honoured. Every attempt counts against $deletions_left (shared with step 4). + */ +function ct_scan_apply_tombstones( $now, $deadline = null, &$deletions_left = null ) { + global $wpdb; + $table = ct_scan_jobs_table(); + $stones = ct_scan_tombstones_table(); + $deadline = null === $deadline ? microtime( true ) + 3.0 : $deadline; + if ( null === $deletions_left ) { + $deletions_left = ct_scan_retention_batch_limit(); + } + // Candidates by numeric ids and creation time; the keyed scope MAC is compared in PHP, under the lock. + // Rows that cannot be acted on now (held or in grace, holding a lease) are excluded in SQL. The rest are + // paged by key from where the previous run stopped, wrapping around once, so rows that are skipped or + // whose deletion keeps failing can never starve later restored rows. + $grace_edge = ( new DateTimeImmutable( $now, new DateTimeZone( 'UTC' ) ) )->modify( '-' . ct_scan_hold_grace_days() . ' days' )->format( 'Y-m-d H:i:s' ); + $cursor = max( 0, (int) get_option( 'ct_scan_tombstone_cursor', 0 ) ); + $after_job = $cursor; + $after_id = 0; + $upper = PHP_INT_MAX; + $wrapped = 0 === $cursor; + $finished = false; + // Part of the budget is left for the restored-post loop below, so restored job rows whose deletion keeps + // failing can never starve it. + $post_reserve = intdiv( max( 0, (int) $deletions_left ), 5 ); + for ( $page = 0; $page < 40 && ! $finished; $page++ ) { + $ghosts = $wpdb->get_results( $wpdb->prepare( + 'SELECT j.job_id, t.tombstone_id, t.scope_mac, t.identity FROM ' . $table . ' j INNER JOIN ' . $stones . ' t ON t.job_id = j.job_id AND t.request_id = j.request_id AND t.created_at = j.created_at' + . ' WHERE (j.job_id > %d OR (j.job_id = %d AND t.tombstone_id > %d)) AND j.job_id <= %d' + . " AND (j.hold_reason IS NULL OR (j.hold_until >= '2020-01-01 00:00:00' AND j.hold_until <= %s))" + . " AND j.status NOT IN ('leased','running') AND NOT (j.status = 'awaiting_control' AND COALESCE(j.control_state, '') = 'checking')" + . ' ORDER BY j.job_id ASC, t.tombstone_id ASC LIMIT 50', + $after_job, + $after_job, + $after_id, + $upper, + $grace_edge + ) ); + if ( ct_scan_retention_query_failed( 'tombstone_rows' ) ) { + break; // never "no candidates": stop here and continue from the stored cursor next run + } + if ( empty( $ghosts ) ) { + if ( $wrapped ) { + $finished = true; + break; + } + // Reached the end: start again from the beginning, up to where this run started. + $wrapped = true; + $after_job = 0; + $after_id = 0; + $upper = $cursor; continue; } - if ( 0 === $personal_scrubbed ) { - $scrubbed = $wpdb->get_row( $wpdb->prepare( "SELECT contact_email,contact_name FROM {$table} WHERE job_id = %d", (int) $row->job_id ) ); - if ( ! $scrubbed || '' !== $scrubbed->contact_email || '' !== $scrubbed->contact_name ) { - continue; + foreach ( (array) $ghosts as $ghost ) { + if ( $deletions_left <= $post_reserve ) { + // This loop's share of the budget is spent: the next run continues from here. + break 2; + } + if ( microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { + // Out of time, or the queue is busy: the next run continues from here. + update_option( 'ct_scan_tombstone_cursor', (string) $after_job, false ); + return; + } + $after_job = (int) $ghost->job_id; + $after_id = (int) $ghost->tombstone_id; + try { + $row = ct_scan_row( (int) $ghost->job_id ); + // A restored row that holds a live lease (a scan, or a control check in progress) is left until the + // lease ends (bounded; maintenance reclaims it): deleting it would free the one-at-a-time gate while + // target traffic for it may still be in flight. Re-checked here under the lock. + // The row is also kept until the retention deadline that applies to it today has passed: a live row + // whose retention deletion wrote this tombstone and then failed part-way looks exactly like a restored + // one, and a period raised since must protect it (ct_scan_tombstone_deadline_passed()). + if ( $row && hash_equals( (string) $ghost->scope_mac, ct_scan_tombstone_mac( $row->scope_hash ) ) && ! ct_scan_job_on_hold( $row, $now ) && ! ct_scan_job_holds_lease( $row ) && ct_scan_tombstone_deadline_passed( $row, $now ) ) { + --$deletions_left; + // A failure leaves the row (and is retried next run); it never stops the other candidates. + ct_scan_delete_job_records( $row, 'restored', (string) $ghost->identity ); + } + } finally { + ct_scan_lock_release(); + } + } + } + update_option( 'ct_scan_tombstone_cursor', $finished ? '0' : (string) $after_job, false ); + // A restored request post whose job row was not restored: matched in SQL on the opaque identity + // (never on recyclable numeric ids alone) AND on the job id the post names, and only where no job row + // currently uses that request or job id, so a live record can never be reached. Re-checked under the + // lock before each deletion. Paged by tombstone id from where the previous run stopped, wrapping around + // once, so posts whose deletion is refused (a filter, the database) can never starve later ones. + $postmeta = $wpdb->postmeta; + $post_cursor = max( 0, (int) get_option( 'ct_scan_restored_post_cursor', 0 ) ); + $after_stone = $post_cursor; + $upper_stone = PHP_INT_MAX; + $post_wrapped = 0 === $post_cursor; + $post_done = false; + for ( $page = 0; $page < 40 && ! $post_done; $page++ ) { + $restored = $wpdb->get_results( $wpdb->prepare( + 'SELECT t.tombstone_id, t.job_id, t.request_id, t.identity FROM ' . $stones . ' t' + . ' INNER JOIN ' . $wpdb->posts . " p ON p.ID = t.request_id AND p.post_type = 'ct_promo_request'" + . ' INNER JOIN ' . $postmeta . " m ON m.post_id = t.request_id AND m.meta_key = '_ct_scan_identity' AND CAST(m.meta_value AS BINARY) = CAST(t.identity AS BINARY)" + . ' INNER JOIN ' . $postmeta . " k ON k.post_id = t.request_id AND k.meta_key = '_ct_scan_job_id' AND CAST(k.meta_value AS BINARY) = CAST(t.job_id AS BINARY)" + . " WHERE t.identity IS NOT NULL AND t.identity <> '' AND t.tombstone_id > %d AND t.tombstone_id <= %d" + . ' AND NOT EXISTS (SELECT 1 FROM ' . $table . ' j WHERE j.request_id = t.request_id OR j.job_id = t.job_id)' + . ' GROUP BY t.tombstone_id, t.job_id, t.request_id, t.identity ORDER BY t.tombstone_id ASC LIMIT 50', + $after_stone, + $upper_stone + ) ); + if ( ct_scan_retention_query_failed( 'tombstone_posts' ) ) { + break; // never "no candidates": stop here and continue from the stored cursor next run + } + if ( empty( $restored ) ) { + if ( $post_wrapped ) { + $post_done = true; + break; } + // Reached the end: start again from the beginning, up to where this run started. + $post_wrapped = true; + $after_stone = 0; + $upper_stone = $post_cursor; + continue; } - if ( $post && 'ct_promo_request' === $post->post_type && 'private' === $post->post_status && (int) $post->ID === (int) $row->request_id ) { - if ( ! wp_delete_post( (int) $row->request_id, true ) ) { - continue; + foreach ( (array) $restored as $stone ) { + if ( $deletions_left < 1 || microtime( true ) > $deadline || ! ct_scan_lock_acquire() ) { + // Out of budget or time, or the queue is busy: the next run continues from here. + update_option( 'ct_scan_restored_post_cursor', (string) $after_stone, false ); + return; } + $after_stone = (int) $stone->tombstone_id; + try { + $live = $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$table} WHERE request_id = %d OR job_id = %d LIMIT 1", (int) $stone->request_id, (int) $stone->job_id ) ); + if ( ct_scan_retention_query_failed( 'tombstone_posts' ) ) { + // A failed lookup is not "no live record": keep the post and stop; the next run retries from here. + update_option( 'ct_scan_restored_post_cursor', (string) max( 0, $after_stone - 1 ), false ); + return; + } + clean_post_cache( (int) $stone->request_id ); + if ( ! $live && hash_equals( (string) $stone->identity, ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_identity' ) ) && (string) $stone->job_id === ct_scan_meta_string( (int) $stone->request_id, '_ct_scan_job_id' ) ) { + --$deletions_left; + if ( ! wp_delete_post( (int) $stone->request_id, true ) ) { + // Refused (a filter or the database): the post is kept and retried on a later pass. This + // attempt has been counted and the cursor moves on, so one stuck post can neither spin nor + // block the rest. + clean_post_cache( (int) $stone->request_id ); + } + } + } finally { + ct_scan_lock_release(); + } + } + } + update_option( 'ct_scan_restored_post_cursor', $post_done ? '0' : (string) $after_stone, false ); +} + +/** + * Place a preservation hold. A hold that still protects the job (unexpired, or inside its grace period) and + * has not been released is never replaced: a second "place hold" (a stale tab, two administrators, a direct + * POST) is refused with 'hold_active', so it can neither move the expiry earlier nor erase the stated purpose + * and who set it. To change a standing hold, release it first (the job stays protected through the grace + * period), then place the new one. A released hold in its grace period may be replaced, but never by a hold + * that ends earlier. The write is a compare-and-set on the hold values read under the queue lock. + */ +function ct_scan_admin_set_hold( $job_id, $reason, $days ) { + global $wpdb; + $user = wp_get_current_user(); + if ( ! $user || ! $user->exists() || ! current_user_can( 'manage_options' ) ) { + return new WP_Error( 'hold_forbidden', 'Only an administrator can place a hold.' ); + } + $reason = is_string( $reason ) ? trim( wp_strip_all_tags( $reason ) ) : ''; + $days = is_numeric( $days ) ? (int) $days : 0; + if ( strlen( $reason ) < 20 || strlen( $reason ) > 190 || $days < 1 || $days > 730 ) { + return new WP_Error( 'hold_invalid', 'State the purpose (20 to 190 characters) and a duration of 1 to 730 days.' ); + } + if ( ! ct_scan_lock_acquire( 3 ) ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + try { + $row = ct_scan_row( $job_id ); + if ( ! $row ) { + return new WP_Error( 'hold_unavailable', 'This job no longer exists.' ); + } + $now = ct_scan_utc_now(); + $until = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '+' . $days . ' days' )->format( 'Y-m-d H:i:s' ); + if ( ct_scan_job_on_hold( $row, $now ) && ( empty( $row->hold_released_at ) || ! ct_scan_valid_datetime( $row->hold_until ) || $row->hold_until > $until ) ) { + return new WP_Error( 'hold_active', 'This job already has a preservation hold; it was not changed. Release it first to place a different one.' ); + } + $updated = $wpdb->update( + ct_scan_jobs_table(), + array( 'hold_reason' => $reason, 'hold_until' => $until, 'hold_set_by' => (int) $user->ID, 'hold_set_at' => $now, 'hold_released_by' => null, 'hold_released_at' => null ), + array( 'job_id' => (int) $row->job_id, 'hold_reason' => $row->hold_reason, 'hold_until' => $row->hold_until, 'hold_released_at' => $row->hold_released_at ), + array( '%s', '%s', '%d', '%s', '%d', '%s' ), + array( '%d', '%s', '%s', '%s' ) + ); + if ( false === $updated ) { + return new WP_Error( 'hold_write_failed', 'The hold could not be recorded.' ); + } + if ( 1 !== $updated ) { + return new WP_Error( 'hold_changed', 'The hold changed while this request was handled; reload the queue and try again.' ); + } + } finally { + ct_scan_lock_release(); + } + return true; +} + +/** + * The identity of one specific hold (its purpose, expiry, setter and when it was set), carried by the + * "Release hold" form, so a release can only ever end the hold the administrator was looking at. + */ +function ct_scan_hold_identity( $row ) { + return hash( 'sha256', implode( "\n", array( 'ct-scan-hold-v1', (string) $row->job_id, (string) $row->hold_reason, (string) $row->hold_until, (string) $row->hold_set_at, (string) $row->hold_set_by ) ) ); +} + +/** + * Release the hold identified by $expected_hold (ct_scan_hold_identity() of the hold shown to the + * administrator). A stale form (the hold was released and a different one placed since, by another + * administrator or in another tab) is refused with 'hold_changed' and changes nothing. The write is a + * compare-and-set on the hold's values as read under the queue lock. + */ +function ct_scan_admin_release_hold( $job_id, $expected_hold = '' ) { + global $wpdb; + $user = wp_get_current_user(); + if ( ! $user || ! $user->exists() || ! current_user_can( 'manage_options' ) ) { + return new WP_Error( 'hold_forbidden', 'Only an administrator can release a hold.' ); + } + if ( ! ct_scan_lock_acquire( 3 ) ) { + return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' ); + } + try { + $row = ct_scan_row( $job_id ); + if ( ! $row || empty( $row->hold_reason ) || ! empty( $row->hold_released_at ) ) { + return new WP_Error( 'hold_unavailable', 'This job has no hold to release.' ); + } + if ( ! is_string( $expected_hold ) || 1 !== preg_match( '/\A[a-f0-9]{64}\z/', $expected_hold ) || ! hash_equals( ct_scan_hold_identity( $row ), $expected_hold ) ) { + return new WP_Error( 'hold_changed', 'The hold changed since the queue was loaded; it was not released.' ); + } + // Releasing ends the hold now, it does not erase it: the purpose stays (with who released it and + // when), and the job keeps the same three-day grace as a hold that expires. A hold that already + // expired keeps its own (earlier) end, so a release never lengthens it. + $now = ct_scan_utc_now(); + $until = ( ct_scan_valid_datetime( $row->hold_until ) && $row->hold_until < $now ) ? $row->hold_until : $now; + $updated = $wpdb->update( + ct_scan_jobs_table(), + array( 'hold_until' => $until, 'hold_released_by' => (int) $user->ID, 'hold_released_at' => $now ), + array( 'job_id' => (int) $row->job_id, 'hold_reason' => $row->hold_reason, 'hold_until' => $row->hold_until, 'hold_set_at' => $row->hold_set_at, 'hold_set_by' => $row->hold_set_by, 'hold_released_at' => null ), + array( '%s', '%d', '%s' ), + array( '%d', '%s', '%s', '%s', '%d', '%s' ) + ); + if ( false === $updated ) { + return new WP_Error( 'hold_write_failed', 'The hold could not be released.' ); } - $wpdb->delete( $table, array( 'job_id' => (int) $row->job_id ), array( '%d' ) ); + if ( 1 !== $updated ) { + return new WP_Error( 'hold_changed', 'The hold changed since the queue was loaded; it was not released.' ); + } + } finally { + ct_scan_lock_release(); } + return true; } function ct_scan_maintain() { @@ -864,6 +1667,9 @@ function ct_scan_maintain() { } finally { ct_scan_lock_release(); } + // Retention runs after the queue lock is released (see ct_scan_retention_run()); a failure there never + // breaks the admin page. + ct_scan_retention_run_safely(); return true; } @@ -1440,6 +2246,19 @@ function ct_scan_is_list( $value ) { } function ct_scan_claim( WP_REST_Request $request ) { + $response = ct_scan_claim_locked( $request ); + // The worker polls this route every few seconds; retention piggybacks on it, outside the queue lock, and + // only on a poll that hands out no job, so a lease is never delayed by a retention run. + if ( $response instanceof WP_REST_Response && 200 === $response->get_status() ) { + $data = $response->get_data(); + if ( is_array( $data ) && array_key_exists( 'job', $data ) && null === $data['job'] ) { + ct_scan_retention_run_safely(); // retention must never affect a claim + } + } + return $response; +} + +function ct_scan_claim_locked( WP_REST_Request $request ) { $body = ct_scan_rest_authenticate( $request, '/ct-scan/v1/claim' ); if ( is_wp_error( $body ) ) { return ct_scan_rest_error( (int) $body->get_error_data()['status'], $body->get_error_code() ); @@ -2173,7 +2992,7 @@ function ct_scan_register_rest_routes() { add_action( 'rest_api_init', 'ct_scan_register_rest_routes' ); function ct_scan_terminal_status( $status ) { - return in_array( $status, array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ), true ); + return in_array( $status, ct_scan_terminal_statuses(), true ); } function ct_scan_email_safe_text( $value, $max = 2000 ) { @@ -2420,7 +3239,7 @@ function ct_scan_admin_action_handler() { } $job_id = isset( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0; $action = isset( $_POST['ct_scan_operation'] ) && is_string( $_POST['ct_scan_operation'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_operation'] ) ) : ''; - if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery', 'authority_review' ), true ) ) { + if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery', 'authority_review', 'set_hold', 'release_hold' ), true ) ) { wp_die( esc_html__( 'The requested action is invalid.', 'ct-scan' ), '', array( 'response' => 400 ) ); } check_admin_referer( 'ct_scan_admin_' . $action . '_' . $job_id ); @@ -2430,6 +3249,8 @@ function ct_scan_admin_action_handler() { 'send_report' => 'ct_scan_send_confirm', 'record_delivery' => 'ct_scan_delivery_confirm', 'authority_review' => 'ct_scan_authority_confirm', + 'set_hold' => 'ct_scan_hold_confirm', + 'release_hold' => 'ct_scan_hold_release_confirm', ); $confirm_key = $confirmations[ $action ]; if ( ! isset( $_POST[ $confirm_key ] ) || ! is_string( $_POST[ $confirm_key ] ) || '1' !== wp_unslash( $_POST[ $confirm_key ] ) ) { @@ -2441,6 +3262,10 @@ function ct_scan_admin_action_handler() { $result = ct_scan_admin_save_manual_remainder( $job_id, isset( $_POST['manual_remainder_notes'] ) && is_string( $_POST['manual_remainder_notes'] ) ? $_POST['manual_remainder_notes'] : '' ); } elseif ( 'send_report' === $action ) { $result = ct_scan_admin_send_report( $job_id ); + } elseif ( 'set_hold' === $action ) { + $result = ct_scan_admin_set_hold( $job_id, isset( $_POST['ct_scan_hold_reason'] ) && is_string( $_POST['ct_scan_hold_reason'] ) ? wp_unslash( $_POST['ct_scan_hold_reason'] ) : '', isset( $_POST['ct_scan_hold_days'] ) && is_scalar( $_POST['ct_scan_hold_days'] ) ? wp_unslash( $_POST['ct_scan_hold_days'] ) : 0 ); + } elseif ( 'release_hold' === $action ) { + $result = ct_scan_admin_release_hold( $job_id, isset( $_POST['ct_scan_hold_identity'] ) && is_string( $_POST['ct_scan_hold_identity'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_hold_identity'] ) ) : '' ); } elseif ( 'authority_review' === $action ) { $result = ct_scan_admin_record_authority_review( $job_id, isset( $_POST['ct_scan_authority_note'] ) && is_string( $_POST['ct_scan_authority_note'] ) ? wp_unslash( $_POST['ct_scan_authority_note'] ) : '' ); } else { @@ -2511,7 +3336,12 @@ function ct_scan_admin_page() { echo '

PHP2Shell screening queue

'; echo '

Worker API: ' . ( ct_scan_enabled() ? 'enabled' : 'disabled' ) . '. This page never displays the worker secret. The screening covers public passive evidence only; broader checks remain manual.

'; if ( isset( $_GET['ct_scan_notice'] ) && is_string( $_GET['ct_scan_notice'] ) ) { - echo '

' . esc_html( sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ) ) . '

'; + $notice = sanitize_key( wp_unslash( $_GET['ct_scan_notice'] ) ); + $messages = array( + 'hold_active' => 'Not changed: this job already has a preservation hold. A second hold cannot replace or shorten it; release the existing hold first (the records stay protected through the grace period), then place the new one.', + 'hold_changed' => 'Not changed: the hold was modified since this page was loaded (for example, released and replaced by another administrator). Reload the queue to see the current hold, then try again.', + ); + echo '

' . esc_html( $messages[ $notice ] ?? $notice ) . '

'; } if ( empty( $rows ) ) { echo '

No screening jobs are stored.

'; @@ -2521,7 +3351,7 @@ function ct_scan_admin_page() { foreach ( $rows as $row ) { $request_url = admin_url( 'post.php?post=' . (int) $row->request_id . '&action=edit' ); $host = wp_parse_url( $row->target_origin, PHP_URL_HOST ); - $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nTarget control: " . ( $row->control_required ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . '…' : '—' ); + $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nTarget control: " . ( $row->control_required ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) . "\nClosed: " . ( $row->closed_at ? $row->closed_at . ' UTC' : '—' ) . "\nEvidence deletion date: " . ( $row->retain_until ? $row->retain_until . ' UTC' : ( $row->closed_at ? 'unknown (kept)' : '—' ) ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . '…' : '—' ); $mail = 'Confirmation email: ' . sanitize_key( $row->confirmation_email_status ) . "\nReport transport: " . sanitize_key( $row->report_notification_status ) . "\nDelivery confirmed: " . ( $row->report_delivered_at ? $row->report_delivered_at . ' UTC' : 'no' ) . "\nRevoked: " . ( $row->revoked_at ? $row->revoked_at . ' UTC' : 'no' ); echo 'Job #' . (int) $row->job_id . ' / request #' . (int) $row->request_id . '
Created ' . esc_html( $row->created_at ) . ' UTC'; echo '' . esc_html( $row->target_origin ) . '
Terms ' . esc_html( $row->terms_version ) . '
Accepted ' . esc_html( $row->terms_accepted_at ) . ' UTC
Expires ' . esc_html( $row->authorization_expires_at ) . ' UTC
Scope hash ' . esc_html( substr( $row->scope_hash, 0, 16 ) ) . '…'; @@ -2547,6 +3377,16 @@ function ct_scan_admin_page() { if ( 'awaiting_control' === $row->status && empty( $row->revoked_at ) ) { echo ct_scan_admin_form( (int) $row->job_id, 'authority_review', 'Record authority review', '

' ); } + $on_hold = ct_scan_job_on_hold( $row, ct_scan_utc_now() ); + if ( $on_hold && empty( $row->hold_released_at ) ) { + echo '

Preservation hold until ' . esc_html( $row->hold_until ) . ' UTC: ' . esc_html( $row->hold_reason ) . '

' . ct_scan_admin_form( (int) $row->job_id, 'release_hold', 'Release hold', ' ' ); + } else { + if ( $on_hold ) { + // Released, still inside the grace period: show it, and allow a new hold. + echo '

Preservation hold released ' . esc_html( $row->hold_released_at ) . ' UTC (records kept for a further ' . (int) ct_scan_hold_grace_days() . ' days): ' . esc_html( $row->hold_reason ) . '

'; + } + echo ct_scan_admin_form( (int) $row->job_id, 'set_hold', 'Place preservation hold', '
days
' ); + } if ( empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) { echo ct_scan_admin_form( (int) $row->job_id, 'revoke', 'Revoke authorization', ' ' ); } diff --git a/integrations/wordpress/tests/admission.php b/integrations/wordpress/tests/admission.php index 6119afc..bce20a9 100644 --- a/integrations/wordpress/tests/admission.php +++ b/integrations/wordpress/tests/admission.php @@ -299,11 +299,12 @@ function ct_test_gate( $env ) { }; add_filter( 'dbdelta_queries', $count ); ct_scan_maybe_install_schema(); +$first_run = $runs; // the tables dbDelta is asked to build in one upgrade attempt ct_scan_maybe_install_schema(); ct_scan_maybe_install_schema(); remove_filter( 'dbdelta_queries', $count ); $logged = (string) file_get_contents( $log ); -ct_test_assert( 2 === $runs, "dbDelta ran $runs times (two tables, once) for three requests after a failed upgrade." ); +ct_test_assert( $first_run >= 2 && $runs === $first_run, "dbDelta ran $runs times for three requests after a failed upgrade (one attempt is $first_run)." ); ct_test_assert( 1 === substr_count( $logged, 'ct-scan: database upgrade' ) && false !== get_transient( 'ct_scan_schema_backoff' ), 'A failed upgrade was not logged exactly once with a back-off.' ); ct_test_assert( 503 === ct_test_admit( $jobc )->get_status() && '2' === get_option( 'ct_scan_schema_version' ), 'The worker API was not refused during an incomplete upgrade.' ); delete_transient( 'ct_scan_schema_backoff' ); diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 5320b55..4e999d7 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -218,19 +218,19 @@ $retention_event = ct_scan_on_request_ready( $retention_id, $retention_fields ); $old_finished = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-13 months' )->format( 'Y-m-d H:i:s' ); $wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => $old_finished, 'terms_accepted_at' => $old_finished, 'result_json' => '{}', 'manual_remainder_notes' => 'private notes' ), array( 'job_id' => (int) $retention_event['job_id'] ) ); -ct_scan_maintenance_locked(); +ct_test_maintain(); $retained = ct_scan_row( $retention_event['job_id'] ); ct_test_assert( $retained && null === $retained->result_json && null === $retained->manual_remainder_notes && get_post( $retention_id ), '12-month cleanup must clear result/manual data but retain the request evidence.' ); $three_years = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years -1 day' )->format( 'Y-m-d H:i:s' ); -$wpdb->update( $jobs, array( 'terms_accepted_at' => $three_years, 'finished_at' => $three_years ), array( 'job_id' => (int) $retention_event['job_id'] ) ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $three_years, 'finished_at' => $three_years, 'closed_at' => $three_years, 'created_at' => $three_years, 'retain_until' => null ), array( 'job_id' => (int) $retention_event['job_id'] ) ); $hold_delete = static function ( $check, $post ) use ( $retention_id ) { return (int) $retention_id === (int) $post->ID ? false : $check; }; add_filter( 'pre_delete_post', $hold_delete, 10, 2 ); -ct_scan_maintenance_locked(); +ct_test_maintain(); remove_filter( 'pre_delete_post', $hold_delete, 10 ); ct_test_assert( ct_scan_row( $retention_event['job_id'] ) && get_post( $retention_id ) && '' === ct_scan_row( $retention_event['job_id'] )->contact_email && '' === ct_scan_row( $retention_event['job_id'] )->contact_name, 'Failed three-year post deletion must retain the job link while scrubbing contact fields.' ); -ct_scan_maintenance_locked(); +ct_test_maintain(); ct_test_assert( ! ct_scan_row( $retention_event['job_id'] ) && ! get_post( $retention_id ), 'Three-year cleanup must remove the exact linked private request and job.' ); // Remove only records created by this test. diff --git a/integrations/wordpress/tests/lock-holder.php b/integrations/wordpress/tests/lock-holder.php index 3ad867b..66d877d 100644 --- a/integrations/wordpress/tests/lock-holder.php +++ b/integrations/wordpress/tests/lock-holder.php @@ -2,11 +2,16 @@ /** Test helper: hold the queue lock for N seconds, simulating an admission in progress. */ require __DIR__ . '/support.php'; $seconds = isset( $argv[1] ) ? max( 0.1, min( 5, (float) $argv[1] ) ) : 1.0; -if ( ! ct_scan_lock_acquire() ) { +$retention = isset( $argv[2] ) && 'retention' === $argv[2]; +if ( ! ( $retention ? ct_scan_retention_lock_acquire() : ct_scan_lock_acquire() ) ) { fwrite( STDOUT, "busy\n" ); exit( 1 ); } fwrite( STDOUT, "locked\n" ); fflush( STDOUT ); usleep( (int) ( $seconds * 1000000 ) ); -ct_scan_lock_release(); +if ( $retention ) { + ct_scan_retention_lock_release(); +} else { + ct_scan_lock_release(); +} diff --git a/integrations/wordpress/tests/retention-maintain.php b/integrations/wordpress/tests/retention-maintain.php new file mode 100644 index 0000000..5f6ac7d --- /dev/null +++ b/integrations/wordpress/tests/retention-maintain.php @@ -0,0 +1,5 @@ + '2027-06-01-r3', 'closed_at' => '2026-01-01 00:00:00', 'terms_accepted_at' => '2025-01-01 00:00:00' ); +$old = (object) array( 'terms_version' => '2026-10-21-r2', 'closed_at' => '2026-01-01 00:00:00', 'terms_accepted_at' => '2025-01-01 00:00:00' ); +echo ct_scan_retain_until( $row ) . ' ' . ct_scan_retain_until( $old ) . "\n"; diff --git a/integrations/wordpress/tests/retention.php b/integrations/wordpress/tests/retention.php new file mode 100644 index 0000000..64443e8 --- /dev/null +++ b/integrations/wordpress/tests/retention.php @@ -0,0 +1,944 @@ +query( "DELETE FROM {$jobs} WHERE job_id >= 888000000 OR job_id BETWEEN 887000000 AND 887999999" ); +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() ); +$request_ids = array(); +add_filter( 'pre_wp_mail', '__return_true' ); + +function ct_test_old( $modifier ) { + return ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( $modifier )->format( 'Y-m-d H:i:s' ); +} + +/** A job in $status whose acceptance and closure are the given ages. */ +function ct_test_aged_job( $host, $status, $accepted, $closed, $version = '2026-10-21-r2' ) { + global $wpdb, $request_ids; + list( $request_id, $fields ) = ct_test_request( $host ); + $request_ids[] = $request_id; + $event = ct_scan_on_request_ready( $request_id, $fields ); + ct_test_assert( is_array( $event ), 'Retention fixture job was not created.' ); + $update = array( 'status' => $status, 'terms_accepted_at' => ct_test_old( $accepted ), 'created_at' => ( new DateTimeImmutable( ct_test_old( $accepted ), new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'terms_version' => $version, 'confirmation_token_hash' => null ); + if ( null !== $closed ) { + $update['finished_at'] = ct_test_old( $closed ); + $update['result_json'] = '{"fixture":true}'; + $update['manual_remainder_notes'] = 'private findings'; + } + $wpdb->update( ct_scan_jobs_table(), $update, array( 'job_id' => (int) $event['job_id'] ) ); + return array( 'job_id' => (int) $event['job_id'], 'request_id' => $request_id ); +} + +function ct_test_exists( $job ) { + return (bool) ct_scan_row( $job['job_id'] ); +} + +// ---- Closure is explicit, and only terminal jobs close --------------------------------------------------- +list( $live_request, $live_fields ) = ct_test_request( 'ret-live.example.net' ); +$request_ids[] = $live_request; +$live_event = ct_scan_on_request_ready( $live_request, $live_fields ); +ct_test_queue_job( $live_event['job_id'] ); +$live = array( 'job_id' => (int) $live_event['job_id'], 'request_id' => $live_request ); +$closing = ct_test_aged_job( 'ret-closing.example.net', 'failed', '-1 day', '-1 hour' ); +$blocked = ct_test_aged_job( 'ret-blocked.example.net', 'blocked', '-2 days', '-1 day' ); +ct_test_maintain(); +$row = ct_scan_row( $closing['job_id'] ); +ct_test_assert( $row->closed_at === $row->finished_at && ! empty( $row->retain_until ), 'A finished job was not stamped closed with a deletion date.' ); +ct_test_assert( null === ct_scan_row( $live['job_id'] )->closed_at && null === ct_scan_row( $live['job_id'] )->retain_until, 'A job that can still run was stamped closed.' ); +ct_test_assert( ! empty( ct_scan_row( $blocked['job_id'] )->closed_at ), 'A blocked job was not closed.' ); +ct_test_assert( ct_test_exists( $live ) && get_post( $live['request_id'] ), 'A job that can still run was deleted or closed by retention.' ); + +// r2: three years from acceptance. Other versions: six years after closure. +$r2 = ct_scan_row( $closing['job_id'] ); +ct_test_assert( $r2->retain_until === ( new DateTimeImmutable( $r2->terms_accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'r2 evidence is not kept three years from acceptance.' ); +$newer = ct_test_aged_job( 'ret-newer.example.net', 'cancelled', '-1 year', '-1 month', '2027-06-01-r3' ); +ct_test_maintain(); +$nr = ct_scan_row( $newer['job_id'] ); +ct_test_assert( $nr->retain_until === ( new DateTimeImmutable( $nr->closed_at, new DateTimeZone( 'UTC' ) ) )->modify( '+6 years' )->format( 'Y-m-d H:i:s' ), 'A newer version is not kept six years after closure.' ); + +// The date is set at closure (and can only be extended when it comes due): a later change of the stored acceptance date does not move it. +$fixed = $nr->retain_until; +$wpdb->update( $jobs, array( 'terms_accepted_at' => ct_test_old( '-9 years' ) ), array( 'job_id' => $newer['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_scan_row( $newer['job_id'] )->retain_until === $fixed, 'The deletion date was recomputed after closure.' ); + +// An accepted-but-unverified request that expires is closed too. +list( $unverified_id, $unverified_fields ) = ct_test_request( 'ret-unverified.example.net' ); +$request_ids[] = $unverified_id; +$unverified = ct_scan_on_request_ready( $unverified_id, $unverified_fields ); +$wpdb->update( $jobs, array( 'confirmation_expires_at' => ct_test_old( '-1 hour' ) ), array( 'job_id' => $unverified['job_id'] ) ); +ct_test_maintain(); +$u = ct_scan_row( $unverified['job_id'] ); +ct_test_assert( 'expired' === $u->status && ! empty( $u->closed_at ) && ! empty( $u->retain_until ), 'An unconfirmed request that expired was not closed with a deletion date.' ); + +// ---- Findings: twelve months after closure; evidence is kept ------------------------------------------------ +$young = ct_test_aged_job( 'ret-young.example.net', 'completed', '-2 years', '-11 months' ); +$old = ct_test_aged_job( 'ret-old.example.net', 'completed', '-2 years', '-13 months' ); +ct_test_maintain(); +ct_test_assert( null !== ct_scan_row( $young['job_id'] )->result_json && null !== ct_scan_row( $young['job_id'] )->manual_remainder_notes, 'Findings were cleared before twelve months.' ); +$o = ct_scan_row( $old['job_id'] ); +ct_test_assert( null === $o->result_json && null === $o->manual_remainder_notes && ct_test_exists( $old ) && get_post( $old['request_id'] ), 'Findings were not cleared at twelve months, or the signed evidence went with them.' ); + +// ---- Evidence deadlines -------------------------------------------------------------------------------------- +$edge_keep = ct_test_aged_job( 'ret-keep.example.net', 'failed', '-2 years -11 months', '-1 day' ); +$edge_del = ct_test_aged_job( 'ret-del.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$six_keep = ct_test_aged_job( 'ret-six-keep.example.net', 'cancelled', '-6 years -2 days', '-5 years -11 months', '2027-06-01-r3' ); +$six_del = ct_test_aged_job( 'ret-six-del.example.net', 'cancelled', '-6 years -2 days', '-6 years -1 day', '2027-06-01-r3' ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $edge_keep ) && get_post( $edge_keep['request_id'] ), 'r2 evidence was deleted before three years.' ); +ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'r2 evidence was not deleted after three years.' ); +ct_test_assert( ct_test_exists( $six_keep ) && get_post( $six_keep['request_id'] ), 'Six-year evidence was deleted early.' ); +ct_test_assert( ! ct_test_exists( $six_del ) && ! get_post( $six_del['request_id'] ), 'Six-year evidence was not deleted after its period.' ); + +// ---- Tombstones: identifiers only ---------------------------------------------------------------------------- +$stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $edge_del['job_id'] ) ); +ct_test_assert( $stone && (int) $stone->request_id === $edge_del['request_id'] && 'retention' === $stone->reason && 64 === strlen( $stone->scope_mac ), 'No tombstone recorded the deletion.' ); +// The tombstone holds a keyed MAC of the scope, not the plain hash of the hostname (nobody reading a backup can test for a customer). +$plain = hash( 'sha256', 'https://ret-del.example.net/' ); +ct_test_assert( $stone->scope_mac === ct_scan_tombstone_mac( $plain ) && $stone->scope_mac !== $plain && ! in_array( 'scope_hash', $wpdb->get_col( 'SHOW COLUMNS FROM ' . ct_scan_tombstones_table(), 0 ), true ) && false === strpos( wp_json_encode( $wpdb->get_results( 'SELECT * FROM ' . ct_scan_tombstones_table() ) ), $plain ), 'The tombstone exposes the plain scope hash.' ); +ct_test_assert( ct_scan_tombstone_mac( $plain ) !== ct_scan_tombstone_mac( hash( 'sha256', 'https://other.example.net/' ) ), 'The scope MAC does not distinguish scopes.' ); +$columns = $wpdb->get_col( 'SHOW COLUMNS FROM ' . ct_scan_tombstones_table(), 0 ); +ct_test_assert( array() === array_intersect( $columns, array( 'contact_email', 'contact_name', 'target_origin', 'result_json', 'terms_hash', 'scope_hash' ) ), 'The tombstone table can hold personal or customer data.' ); + +// A restored backup brings the rows back: maintenance deletes them again. +$ghost = array( 'job_id' => $edge_del['job_id'], 'request_id' => $edge_del['request_id'], 'target_origin' => 'https://ret-del.example.net/', 'scope_hash' => $plain, 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-3 years -1 day' ), 'retention_checked_at' => ct_scan_utc_now(), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored Person', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $stone->created_at, 'finished_at' => ct_test_old( '-1 day' ), 'closed_at' => ct_test_old( '-1 day' ) ); +$wpdb->insert( $jobs, $ghost ); +$restored_post = wp_insert_post( array( 'import_id' => $edge_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $edge_del['request_id'], '_ct_scan_job_id', $edge_del['job_id'] ); +update_post_meta( $edge_del['request_id'], '_ct_scan_identity', $stone->identity ); +ct_test_assert( 32 === strlen( (string) $stone->identity ), 'The tombstone did not keep the opaque identity.' ); +ct_test_assert( ct_test_exists( $edge_del ) && get_post( $edge_del['request_id'] ), 'Fixture failed to restore the deleted records.' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $edge_del ) && ! get_post( $edge_del['request_id'] ), 'A restored backup resurrected deleted records.' ); +// A different job that merely reuses the identifier is not touched. +$wpdb->insert( $jobs, array_merge( $ghost, array( 'created_at' => ct_test_old( '-1 day' ), 'status' => 'failed', 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'other@example.com' ) ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $edge_del ), 'A job that is not the deleted one matched the tombstone.' ); +$wpdb->delete( $jobs, array( 'job_id' => $edge_del['job_id'] ) ); +// A post with the same id but another job id is not deleted either. +$other = wp_insert_post( array( 'import_id' => $six_del['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'unrelated' ) ); +update_post_meta( $six_del['request_id'], '_ct_scan_job_id', 999999999 ); +ct_test_maintain(); +ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted a request that belongs to a different job.' ); +// Same id and job id but not the same identity (a recycled id after a restore): untouched. +$six_stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $six_del['job_id'] ) ); +update_post_meta( $six_del['request_id'], '_ct_scan_job_id', $six_del['job_id'] ); +update_post_meta( $six_del['request_id'], '_ct_scan_identity', str_repeat( 'f', 32 ) ); +ct_test_maintain(); +ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted a request with a different identity.' ); +// Same identity but a live job row now uses that request id: a recycled id must never reach a live record. +update_post_meta( $six_del['request_id'], '_ct_scan_identity', $six_stone->identity ); +$wpdb->insert( $jobs, array( 'job_id' => 888000001, 'request_id' => $six_del['request_id'], 'target_origin' => 'https://recycled.example.net/', 'scope_hash' => str_repeat( 'c', 64 ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'live@example.com', 'contact_name' => 'Live', 'referrer' => 'direct', 'status' => 'awaiting_confirmation', 'created_at' => ct_test_old( '-1 hour' ) ) ); +ct_test_maintain(); +ct_test_assert( get_post( $six_del['request_id'] ), 'A tombstone deleted the request of a live job that reuses its id.' ); +$wpdb->delete( $jobs, array( 'job_id' => 888000001 ) ); +// With no live row, a matching identity does delete a restored post, in any post status. +wp_update_post( array( 'ID' => $six_del['request_id'], 'post_status' => 'trash' ) ); +ct_test_maintain(); +ct_test_assert( ! get_post( $six_del['request_id'] ), 'A restored request in the trash was not deleted.' ); +// Tombstones expire after the backup window, not before. +$wpdb->update( ct_scan_tombstones_table(), array( 'deleted_at' => ct_test_old( '-100 days' ) ), array( 'job_id' => $edge_del['job_id'] ) ); +$wpdb->update( ct_scan_tombstones_table(), array( 'deleted_at' => ct_test_old( '-130 days' ) ), array( 'job_id' => $six_del['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( null !== $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $edge_del['job_id'] ) ), 'A tombstone was purged inside the backup window.' ); +ct_test_assert( null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $six_del['job_id'] ) ), 'A tombstone outlived the backup window.' ); + +// ---- Narrow preservation holds --------------------------------------------------------------------------------- +$held = ct_test_aged_job( 'ret-held.example.net', 'failed', '-3 years -1 day', '-13 months' ); +wp_set_current_user( 0 ); +ct_test_assert( 'hold_forbidden' === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 )->get_error_code(), 'An anonymous caller placed a hold.' ); +$subscriber = wp_insert_user( array( 'user_login' => 'ct_ret_sub_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'subscriber' ) ); +wp_set_current_user( (int) $subscriber ); +ct_test_assert( 'hold_forbidden' === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 )->get_error_code() && 'hold_forbidden' === ct_scan_admin_release_hold( $held['job_id'] )->get_error_code(), 'A logged-in non-administrator managed a hold.' ); +wp_set_current_user( 1 ); +foreach ( array( array( 'too short', 30 ), array( str_repeat( 'x', 191 ), 30 ), array( 'Preserving records for dispute number 123.', 0 ), array( 'Preserving records for dispute number 123.', 731 ), array( 'Preserving records for dispute number 123.', 'forever' ), array( 'Preserving records for dispute number 123.', -5 ) ) as $i => $bad ) { + ct_test_assert( 'hold_invalid' === ct_scan_admin_set_hold( $held['job_id'], $bad[0], $bad[1] )->get_error_code(), "Invalid hold case $i was accepted." ); +} +ct_test_assert( true === ct_scan_admin_set_hold( $held['job_id'], 'Preserving records for dispute number 123.', 30 ), 'A valid hold was refused.' ); +$h = ct_scan_row( $held['job_id'] ); +ct_test_assert( 'Preserving records for dispute number 123.' === $h->hold_reason && 1 === (int) $h->hold_set_by && $h->hold_until > ct_scan_utc_now() && $h->hold_until <= ct_test_old( '+31 days' ), 'The hold does not record its purpose, setter and expiry.' ); +ct_test_maintain(); +$h = ct_scan_row( $held['job_id'] ); +ct_test_assert( $h && null !== $h->result_json && null !== $h->manual_remainder_notes && get_post( $held['request_id'] ), 'A held job lost records.' ); +// A hold that just expired still preserves for the grace period, so nothing vanishes the minute it lapses. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $held['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $held ) && null !== ct_scan_row( $held['job_id'] )->result_json, 'A hold that expired a minute ago released its records at once.' ); +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-4 days' ) ), array( 'job_id' => $held['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $held ) && ! get_post( $held['request_id'] ), 'An expired hold still pinned the record after its grace period.' ); +// The tombstone path honours a hold too: a restored row under a hold is kept. +$restore_held = ct_test_aged_job( 'ret-restore-held.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $restore_held ), 'Fixture expected the job to be deleted first.' ); +$rstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $restore_held['job_id'] ) ); +$wpdb->insert( $jobs, array( 'job_id' => $restore_held['job_id'], 'request_id' => $restore_held['request_id'], 'target_origin' => 'https://ret-restore-held.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-restore-held.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'held@example.com', 'contact_name' => 'Held', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => $rstone->created_at, 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $restore_held ), 'A restored job under a hold was deleted by its tombstone.' ); +$wpdb->delete( $jobs, array( 'job_id' => $restore_held['job_id'] ) ); + +// ---- Untrustworthy dates and doubtful rows are kept, never deleted -------------------------------------------- +$zero_r2 = ct_test_aged_job( 'ret-zero-r2.example.net', 'failed', '-3 years -1 day', '-1 hour' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => '0000-00-00 00:00:00', 'closed_at' => null, 'retain_until' => null ), array( 'job_id' => $zero_r2['job_id'] ) ); +$zero_new = ct_test_aged_job( 'ret-zero-new.example.net', 'cancelled', '-1 year', '-1 hour', '2027-06-01-r3' ); +$wpdb->update( $jobs, array( 'finished_at' => '0000-00-00 00:00:00', 'closed_at' => '0000-00-00 00:00:00', 'retain_until' => null ), array( 'job_id' => $zero_new['job_id'] ) ); +$garbled = ct_test_aged_job( 'ret-garbled.example.net', 'failed', '-3 years -1 day', '-1 hour' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => '1999-01-01 00:00:00', 'closed_at' => null, 'retain_until' => null ), array( 'job_id' => $garbled['job_id'] ) ); +ct_test_maintain(); +ct_test_maintain(); +foreach ( array( $zero_r2, $zero_new, $garbled ) as $case ) { + $r = ct_scan_row( $case['job_id'] ); + ct_test_assert( $r && null === $r->retain_until && get_post( $case['request_id'] ), 'A job with an untrustworthy date was given a deletion date or deleted.' ); +} +ct_test_assert( null !== ct_scan_row( $zero_new['job_id'] )->result_json, 'Findings were cleared on the strength of a zero date.' ); +// A stored date earlier than the one derived today, or earlier than three years after creation, is not honoured. +$tampered = ct_test_aged_job( 'ret-tampered.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $tampered ), 'Fixture expected a normal due job to be deleted.' ); +$early = ct_test_aged_job( 'ret-early.example.net', 'failed', '-2 years', '-1 day' ); +ct_test_maintain(); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $early['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $early ) && get_post( $early['request_id'] ), 'A retain_until earlier than three years after creation (or the derived date) deleted the evidence.' ); +$er = ct_scan_row( $early['job_id'] ); +ct_test_assert( $er->retain_until === ( new DateTimeImmutable( $er->terms_accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A stored date short of the derived one was not extended to the derived date.' ); +// The derived-date check alone (the creation floor passes): accepted two years ago, created four years ago. +$early2 = ct_test_aged_job( 'ret-early2.example.net', 'failed', '-2 years', '-1 day' ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-4 years' ) ), array( 'job_id' => $early2['job_id'] ) ); +ct_test_maintain(); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $early2['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $early2 ), 'A retain_until earlier than the date derived from the row deleted the evidence.' ); +// The creation floor alone: a record created long after its acceptance date is not deleted on that date. +$late_created = ct_test_aged_job( 'ret-late-created.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-2 days' ) ), array( 'job_id' => $late_created['job_id'] ) ); // still before its closure +ct_test_maintain(); +ct_test_assert( ct_test_exists( $late_created ), 'A record created after its stated acceptance date was deleted on that date.' ); +// ... but it is not kept forever either: it gets three full years from creation (later than the promised +// three years from acceptance), a definite date. +$lc = ct_scan_row( $late_created['job_id'] ); +ct_test_assert( $lc->retain_until === ( new DateTimeImmutable( $lc->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A legacy record created after its stated acceptance did not get a definite deadline three years after creation.' ); +// The same case once that later date has come: kept on the run that finds the shorter date, deleted when the later one arrives. +$late_due = ct_test_aged_job( 'ret-late-due.example.net', 'failed', '-3 years -5 days', '-1 day' ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-3 years -1 hour' ) ), array( 'job_id' => $late_due['job_id'] ) ); +ct_test_maintain(); +$ld = ct_scan_row( $late_due['job_id'] ); +ct_test_assert( $ld && get_post( $late_due['request_id'] ) && $ld->retain_until === ( new DateTimeImmutable( $ld->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A legacy record created after its acceptance was deleted at the acceptance-based date, or its date was not extended.' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $late_due ) && ! get_post( $late_due['request_id'] ), 'A legacy record created after its acceptance was never deleted at its extended date.' ); +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '+1 year' ) ), array( 'job_id' => $early['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $early ) && ct_scan_row( $early['job_id'] )->retain_until > ct_test_old( '+1 day' ), 'A later stored deletion date was shortened.' ); +// A job that can still run is never deleted even if its dates say it is due. +$running = ct_test_aged_job( 'ret-running.example.net', 'awaiting_confirmation', '-3 years -1 day', null ); +$wpdb->update( $jobs, array( 'closed_at' => ct_test_old( '-1 day' ), 'retain_until' => ct_test_old( '-1 hour' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'confirmation_expires_at' => ct_test_old( '+1 day' ) ), array( 'job_id' => $running['job_id'] ) ); +ct_test_maintain(); +$rr = ct_scan_row( $running['job_id'] ); +ct_test_assert( $rr && get_post( $running['request_id'] ) && null === $rr->closed_at && null === $rr->retain_until, 'A job that can still run was deleted, or kept a closure stamp, because its dates said so.' ); +// Reopening clears the closure stamp so the old date cannot delete it later. +$reopen = ct_test_aged_job( 'ret-reopen.example.net', 'failed', '-1 day', '-1 hour' ); +ct_test_maintain(); +ct_test_assert( null !== ct_scan_row( $reopen['job_id'] )->retain_until, 'Fixture expected a closed job.' ); +$wpdb->update( $jobs, array( 'status' => 'awaiting_confirmation' ), array( 'job_id' => $reopen['job_id'] ) ); +ct_test_maintain(); +$ro = ct_scan_row( $reopen['job_id'] ); +ct_test_assert( null === $ro->closed_at && null === $ro->retain_until, 'Reopening a job left its old closure and deletion date.' ); +// A terminal job with no finished_at is closed now, not left open. +$nofinish = ct_test_aged_job( 'ret-nofinish.example.net', 'cancelled', '-1 day', null ); +ct_test_maintain(); +ct_test_assert( ! empty( ct_scan_row( $nofinish['job_id'] )->closed_at ), 'A terminal job without finished_at was never closed.' ); + +// ---- Version rules and period floors ---------------------------------------------------------------------------- +foreach ( array( '2026-01-01-r1' => true, '2026-10-21-r1' => true, '2026-10-21-r2' => true, '2026-10-21-r3' => false, '2026-10-22-r1' => false, '2027-06-01-r3' => false, 'weird' => false, '' => false, '2026-10-21-r2 ' => false, '2020-01-01-r9999' => false ) as $version => $legacy ) { + ct_test_assert( $legacy === ct_scan_is_legacy_version( $version ), "Version '$version' was classified wrongly." ); +} +ct_test_assert( 3 === ct_scan_clamp_years( 1 ) && 3 === ct_scan_clamp_years( 0 ) && 3 === ct_scan_clamp_years( 'x' ) && 3 === ct_scan_clamp_years( -6 ) && 3 === ct_scan_clamp_years( null ) && 6 === ct_scan_clamp_years( 6 ) && 10 === ct_scan_clamp_years( '10' ), 'The three-year floor does not hold.' ); +$old_version = ct_test_aged_job( 'ret-old-version.example.net', 'failed', '-2 years', '-1 day', '2026-01-01-r1' ); +$weird_version = ct_test_aged_job( 'ret-weird-version.example.net', 'failed', '-2 years', '-1 day', 'weird' ); +ct_test_maintain(); +$ov = ct_scan_row( $old_version['job_id'] ); +$wv = ct_scan_row( $weird_version['job_id'] ); +ct_test_assert( $ov->retain_until === ( new DateTimeImmutable( $ov->terms_accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A pre-r2 acceptance is not kept three years from acceptance.' ); +ct_test_assert( $wv->retain_until === ( new DateTimeImmutable( $wv->closed_at, new DateTimeZone( 'UTC' ) ) )->modify( '+6 years' )->format( 'Y-m-d H:i:s' ), 'An unrecognised version did not get the longer period.' ); + +// ---- Deleting in any post status; bounded work per run ---------------------------------------------------------- +$trashed = ct_test_aged_job( 'ret-trashed.example.net', 'failed', '-3 years -1 day', '-1 day' ); +wp_update_post( array( 'ID' => $trashed['request_id'], 'post_status' => 'trash' ) ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $trashed ) && ! get_post( $trashed['request_id'] ), 'A trashed request (with the contact details and contract) outlived its deleted job.' ); +$batch = array(); +for ( $i = 0; $i < 53; $i++ ) { + $batch[] = ct_test_aged_job( "ret-batch-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); +} +ct_test_maintain(); +$remaining = count( array_filter( $batch, 'ct_test_exists' ) ); +ct_test_assert( $remaining >= 3 && $remaining < 53, "A single run did not bound its deletions ($remaining of 53 remain)." ); +ct_test_maintain(); +ct_test_maintain(); +ct_test_assert( 0 === count( array_filter( $batch, 'ct_test_exists' ) ), 'The remaining due jobs were not deleted on later runs.' ); + +// Release works; a damaged hold (purpose without expiry) preserves rather than deletes. +$rel = ct_test_aged_job( 'ret-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_scan_admin_set_hold( $rel['job_id'], 'Preserving records for dispute number 456.', 10 ); +ct_test_assert( 'hold_changed' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_changed' === ct_scan_admin_release_hold( $rel['job_id'], str_repeat( 'a', 64 ) )->get_error_code() && empty( ct_scan_row( $rel['job_id'] )->hold_released_at ), 'A release without the identity of the hold shown was accepted.' ); +ct_test_assert( true === ct_scan_admin_release_hold( $rel['job_id'], ct_scan_hold_identity( ct_scan_row( $rel['job_id'] ) ) ), 'Release failed.' ); +$released = ct_scan_row( $rel['job_id'] ); +ct_test_assert( 'Preserving records for dispute number 456.' === $released->hold_reason && $released->hold_until <= ct_scan_utc_now() && $released->hold_until >= ct_test_old( '-1 minute' ) && 1 === (int) $released->hold_released_by && ! empty( $released->hold_released_at ), 'Release did not end the hold now (keeping its purpose) or did not record who released it.' ); +ct_test_assert( 'hold_unavailable' === ct_scan_admin_release_hold( $rel['job_id'] )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 999999999 )->get_error_code() && 'hold_unavailable' === ct_scan_admin_release_hold( 0 )->get_error_code(), 'Releasing a hold that does not exist reported success.' ); +$damaged = ct_test_aged_job( 'ret-damaged.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$wpdb->update( $jobs, array( 'hold_reason' => 'Purpose without an expiry (damaged).', 'hold_until' => null ), array( 'job_id' => $damaged['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $rel ) && get_post( $rel['request_id'] ) && null !== ct_scan_row( $rel['job_id'] )->result_json && ct_test_exists( $damaged ), 'A released hold did not preserve the records for the grace period, or a damaged hold allowed deletion.' ); +// Once the grace period after the release has passed, the job is deleted as usual. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-3 days -1 minute' ) ), array( 'job_id' => $rel['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $rel ) && ! get_post( $rel['request_id'] ) && ct_test_exists( $damaged ), 'A released hold kept the records after its grace period, or a damaged hold allowed deletion.' ); +// Releasing a hold that already expired keeps its own end (a release never lengthens a hold). +$lapsed = ct_test_aged_job( 'ret-lapsed.example.net', 'failed', '-1 day', '-1 hour' ); +ct_scan_admin_set_hold( $lapsed['job_id'], 'Preserving records for dispute number 789.', 10 ); +$lapsed_until = ct_test_old( '-2 days' ); +$wpdb->update( $jobs, array( 'hold_until' => $lapsed_until ), array( 'job_id' => $lapsed['job_id'] ) ); +ct_test_assert( true === ct_scan_admin_release_hold( $lapsed['job_id'], ct_scan_hold_identity( ct_scan_row( $lapsed['job_id'] ) ) ) && $lapsed_until === ct_scan_row( $lapsed['job_id'] )->hold_until, 'Releasing an expired hold lengthened it.' ); +wp_set_current_user( 0 ); +ct_test_assert( 'hold_forbidden' === ct_scan_admin_release_hold( $damaged['job_id'] )->get_error_code() && ct_test_exists( $damaged ) && ! empty( ct_scan_row( $damaged['job_id'] )->hold_reason ), 'An anonymous caller released a hold.' ); + + +// ---- Second review: no starvation, identity backfill, damaged holds, far-future dates --------------------------- +// Rows with a stored date short of what today's rules require (here: a legacy acceptance a year before its +// record was created) are extended, not deleted, and rows with contradictory dates are judged once a day: +// neither can hide a due job. +$doubtful = array(); +for ( $i = 0; $i < 52; $i++ ) { + $d = ct_test_aged_job( "ret-doubtful-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); + $wpdb->update( $jobs, array( 'created_at' => ct_test_old( '-2 days' ) ), array( 'job_id' => $d['job_id'] ) ); // still before its closure + $doubtful[] = $d; +} +// A row whose dates contradict each other (closed before it was created) sorts first and is kept, judged at most daily. +$due_behind = ct_test_aged_job( 'ret-behind-doubtful.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$behind_accepted = ct_test_old( '-3 years -2 days' ); +$behind_closed = ( new DateTimeImmutable( $behind_accepted, new DateTimeZone( 'UTC' ) ) )->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $behind_accepted, 'created_at' => ( new DateTimeImmutable( $behind_accepted, new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'finished_at' => $behind_closed, 'closed_at' => $behind_closed, 'retain_until' => ct_test_old( '-1 year' ) ), array( 'job_id' => $due_behind['job_id'] ) ); +// Due only after all the doubtful rows in date order (so a 50-row window would never reach it). +$really_due = ct_test_aged_job( 'ret-really-due.example.net', 'failed', '-3 years -2 hours', '-1 hour' ); +for ( $run = 0; $run < 3; $run++ ) { + ct_test_maintain(); +} +ct_test_assert( ! ct_test_exists( $really_due ), 'Doubtful rows starved a due job.' ); +ct_test_assert( 52 === count( array_filter( $doubtful, 'ct_test_exists' ) ) && ct_test_exists( $due_behind ), 'Doubtful rows were deleted.' ); +ct_test_assert( ! empty( ct_scan_row( $due_behind['job_id'] )->retention_checked_at ), 'A doubtful row was not marked as examined.' ); +// The rows whose stored date was short of three years after creation were given that later, definite date. +foreach ( $doubtful as $d ) { + $dr = ct_scan_row( $d['job_id'] ); + ct_test_assert( $dr->retain_until === ( new DateTimeImmutable( $dr->created_at, new DateTimeZone( 'UTC' ) ) )->modify( '+3 years' )->format( 'Y-m-d H:i:s' ), 'A row with a stored date short of three years after creation was not given a definite later date.' ); +} +foreach ( array_merge( $doubtful, array( $due_behind ) ) as $d ) { + $wpdb->delete( $jobs, array( 'job_id' => $d['job_id'] ) ); + wp_delete_post( $d['request_id'], true ); +} + +// A job from before the identity existed gets one (adopting what its request carries), so a restore can be recognised. +$legacy = ct_test_aged_job( 'ret-preidentity.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'identity' => null, 'retain_until' => ct_test_old( '+1 day' ) ), array( 'job_id' => $legacy['job_id'] ) ); +delete_post_meta( $legacy['request_id'], '_ct_scan_identity' ); +ct_test_maintain(); +$identity = ct_scan_row( $legacy['job_id'] )->identity; +ct_test_assert( 1 === preg_match( '/\A[a-f0-9]{32}\z/', (string) $identity ) && $identity === ct_scan_meta_string( $legacy['request_id'], '_ct_scan_identity' ), 'A job without an identity was not given one together with its request.' ); +$carried = ct_test_aged_job( 'ret-carried.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'identity' => null, 'retain_until' => ct_test_old( '+1 day' ) ), array( 'job_id' => $carried['job_id'] ) ); +update_post_meta( $carried['request_id'], '_ct_scan_identity', str_repeat( 'a', 32 ) ); +ct_test_maintain(); +ct_test_assert( str_repeat( 'a', 32 ) === ct_scan_row( $carried['job_id'] )->identity && str_repeat( 'a', 32 ) === ct_scan_meta_string( $carried['request_id'], '_ct_scan_identity' ), 'An identity the request already carried was replaced.' ); +// Then delete it and restore the post: it is recognised and deleted again. +$wpdb->update( $jobs, array( 'retain_until' => ct_test_old( '-1 hour' ) ), array( 'job_id' => $legacy['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $legacy ) && ! get_post( $legacy['request_id'] ), 'Fixture expected the pre-identity job to be deleted.' ); +wp_insert_post( array( 'import_id' => $legacy['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $legacy['request_id'], '_ct_scan_job_id', $legacy['job_id'] ); +update_post_meta( $legacy['request_id'], '_ct_scan_identity', $identity ); +ct_test_maintain(); +ct_test_assert( ! get_post( $legacy['request_id'] ), 'The restored request of a pre-identity job was not deleted again.' ); +// The duplicate-event path restores the identity meta of a request that lost it. +list( $dup_id, $dup_fields ) = ct_test_request( 'ret-dup.example.net' ); +$request_ids[] = $dup_id; +$dup_event = ct_scan_on_request_ready( $dup_id, $dup_fields ); +$dup_identity = ct_scan_row( $dup_event['job_id'] )->identity; +ct_test_assert( 1 === preg_match( '/\A[a-f0-9]{32}\z/', (string) $dup_identity ) && $dup_identity === ct_scan_meta_string( $dup_id, '_ct_scan_identity' ), 'A new job does not carry its identity on the request.' ); +delete_post_meta( $dup_id, '_ct_scan_identity' ); +delete_post_meta( $dup_id, '_ct_scan_event_recorded' ); +ct_scan_on_request_ready( $dup_id, $dup_fields ); +ct_test_assert( $dup_identity === ct_scan_meta_string( $dup_id, '_ct_scan_identity' ), 'The duplicate-event path left the request without its identity.' ); + +// A damaged hold (zero expiry) also protects the findings. +$zero_hold = ct_test_aged_job( 'ret-zero-hold.example.net', 'completed', '-2 years', '-13 months' ); +$wpdb->update( $jobs, array( 'hold_reason' => 'Preserving for a dispute (damaged expiry).', 'hold_until' => '0000-00-00 00:00:00' ), array( 'job_id' => $zero_hold['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( null !== ct_scan_row( $zero_hold['job_id'] )->result_json, 'A hold with a zero expiry did not protect the findings.' ); + +// A far-future creation date does not defeat the creation floor. +$future = ct_test_aged_job( 'ret-future.example.net', 'failed', '-3 years -2 days', '-2 days' ); +$wpdb->update( $jobs, array( 'created_at' => '9999-01-01 00:00:00' ), array( 'job_id' => $future['job_id'] ) ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $future ) && ! ct_scan_valid_datetime( '9999-01-01 00:00:00' ) && ! ct_scan_valid_datetime( '10000-01-01 00:00:00' ), 'A far-future date was accepted.' ); + +// A restored request hidden behind many newer tombstones that point at live records is still found. +$buried = ct_test_aged_job( 'ret-buried.example.net', 'failed', '-3 years -2 days', '-2 days' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $buried ), 'Fixture expected the buried job to be deleted.' ); +$buried_stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $buried['job_id'] ) ); +for ( $i = 0; $i < 55; $i++ ) { + $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 770000000 + $i, 'request_id' => 780000000 + $i, 'scope_mac' => str_repeat( 'd', 64 ), 'created_at' => ct_test_old( '-1 hour' ), 'deleted_at' => ct_test_old( '-1 minute' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +} +wp_insert_post( array( 'import_id' => $buried['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $buried['request_id'], '_ct_scan_job_id', $buried['job_id'] ); +update_post_meta( $buried['request_id'], '_ct_scan_identity', $buried_stone->identity ); +ct_test_maintain(); +ct_test_assert( ! get_post( $buried['request_id'] ), 'Newer tombstones hid a restored request from cleanup.' ); + +wp_set_current_user( 1 ); +// Placing a new hold clears the audit of an earlier release. +$reheld = ct_test_aged_job( 'ret-reheld.example.net', 'failed', '-1 day', '-1 hour' ); +ct_scan_admin_set_hold( $reheld['job_id'], 'First preservation purpose for this job.', 10 ); +ct_scan_admin_release_hold( $reheld['job_id'], ct_scan_hold_identity( ct_scan_row( $reheld['job_id'] ) ) ); +ct_scan_admin_set_hold( $reheld['job_id'], 'Second preservation purpose for this job.', 10 ); +$rh = ct_scan_row( $reheld['job_id'] ); +ct_test_assert( null === $rh->hold_released_by && null === $rh->hold_released_at && 'Second preservation purpose for this job.' === $rh->hold_reason, 'A new hold kept the audit of an earlier release.' ); + +// ---- Fourth review: a second hold never shortens or replaces a standing one; identities; tombstone paging --------- +wp_set_current_user( 1 ); +$long_hold = ct_test_aged_job( 'ret-long-hold.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $long_hold ); +ct_test_assert( true === ct_scan_admin_set_hold( $long_hold['job_id'], 'Litigation hold for dispute number 2468.', 730 ), 'A 730-day hold was refused.' ); +$lh = ct_scan_row( $long_hold['job_id'] ); +$second_admin = wp_insert_user( array( 'user_login' => 'ct_ret_admin_' . wp_generate_password( 6, false ), 'user_pass' => wp_generate_password(), 'role' => 'administrator' ) ); +wp_set_current_user( (int) $second_admin ); +foreach ( array( 1, 729, 730 ) as $days ) { + $second = ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', $days ); + $now_lh = ct_scan_row( $long_hold['job_id'] ); + ct_test_assert( is_wp_error( $second ) && 'hold_active' === $second->get_error_code() && $lh->hold_until === $now_lh->hold_until && $lh->hold_reason === $now_lh->hold_reason && (int) $lh->hold_set_by === (int) $now_lh->hold_set_by && $lh->hold_set_at === $now_lh->hold_set_at, "A second hold ($days days) replaced or shortened a standing hold, or lost its purpose or setter." ); +} +ct_test_maintain(); +ct_test_assert( ct_test_exists( $long_hold ) && get_post( $long_hold['request_id'] ), 'A due job lost its records after a refused second hold.' ); +// Inside the grace period of an expired (unreleased) hold, a new hold is refused too: the original purpose and setter stay. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $long_hold['job_id'] ) ); +$lh = ct_scan_row( $long_hold['job_id'] ); +$grace_try = ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', 30 ); +ct_test_assert( is_wp_error( $grace_try ) && 'hold_active' === $grace_try->get_error_code() && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until && $lh->hold_reason === ct_scan_row( $long_hold['job_id'] )->hold_reason, 'A hold in its grace period was replaced without being released.' ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $long_hold ), 'A job in its hold grace period was deleted.' ); +// Released (still in grace): a new hold may be placed, and it never ends earlier than the released one. +ct_test_assert( true === ct_scan_admin_release_hold( $long_hold['job_id'], ct_scan_hold_identity( $lh ) ) && $lh->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until, 'Releasing an expired hold changed its end.' ); +ct_test_assert( true === ct_scan_admin_set_hold( $long_hold['job_id'], 'A different, later purpose for this job.', 30 ), 'A new hold after a release was refused.' ); +$lh2 = ct_scan_row( $long_hold['job_id'] ); +ct_test_assert( 'A different, later purpose for this job.' === $lh2->hold_reason && (int) $second_admin === (int) $lh2->hold_set_by && $lh2->hold_until > ct_test_old( '+29 days' ) && null === $lh2->hold_released_at, 'A new hold after a release was not recorded with its own purpose and setter.' ); +// A damaged state (released, yet an expiry still in the future) is never shortened. +$wpdb->update( $jobs, array( 'hold_until' => ct_test_old( '+300 days' ), 'hold_released_at' => ct_test_old( '-1 hour' ), 'hold_released_by' => 1 ), array( 'job_id' => $long_hold['job_id'] ) ); +$lh3 = ct_scan_row( $long_hold['job_id'] ); +$short = ct_scan_admin_set_hold( $long_hold['job_id'], 'A shorter purpose that must not win.', 5 ); +ct_test_assert( is_wp_error( $short ) && $lh3->hold_until === ct_scan_row( $long_hold['job_id'] )->hold_until && $lh3->hold_reason === ct_scan_row( $long_hold['job_id'] )->hold_reason, 'A hold whose expiry is still in the future was shortened.' ); +wp_set_current_user( 1 ); +$wpdb->delete( $jobs, array( 'job_id' => $long_hold['job_id'] ) ); + +// A due job without its opaque identity is kept until the backfill gives it one (never a tombstone without identity). +$no_identity = (object) array( 'status' => 'failed', 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2027-02-20 12:00:00', 'identity' => null ); +ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A job without an identity was judged deletable.' ); +$no_identity->identity = ''; +ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A job with an empty identity was judged deletable.' ); +unset( $no_identity->identity ); +ct_test_assert( array( 'keep', null ) === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' ), 'A row read without its identity was judged deletable.' ); +$no_identity->identity = str_repeat( 'a', 32 ); +ct_test_assert( 'delete' === ct_scan_deletion_decision( $no_identity, '2028-01-01 00:00:00' )[0], 'Fixture expected the same job with an identity to be deletable.' ); +$empty_identity = ct_test_aged_job( 'ret-empty-identity.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $empty_identity ); +$wpdb->update( $jobs, array( 'identity' => '' ), array( 'job_id' => $empty_identity['job_id'] ) ); +delete_post_meta( $empty_identity['request_id'], '_ct_scan_identity' ); +ct_test_maintain(); +$ei_stone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $empty_identity['job_id'] ) ); +ct_test_assert( ! $ei_stone || 1 === preg_match( '/\A[a-f0-9]{32}\z/', (string) $ei_stone->identity ), 'A job was deleted with an empty identity in its tombstone.' ); +ct_test_assert( null === $wpdb->get_var( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . " WHERE reason = 'retention' AND (identity IS NULL OR identity = '')" ), 'A retention tombstone without an identity exists.' ); +$wpdb->delete( $jobs, array( 'job_id' => $empty_identity['job_id'] ) ); + +// A due job whose linked post has no identity (empty or missing) while the job has one is kept whole. +foreach ( array( 'missing' => null, 'empty' => '' ) as $kind => $value ) { + $blank = ct_test_aged_job( "ret-blank-post-{$kind}.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( $blank ); + if ( null === $value ) { + delete_post_meta( $blank['request_id'], '_ct_scan_identity' ); + } else { + update_post_meta( $blank['request_id'], '_ct_scan_identity', $value ); + } + ct_test_maintain(); + $br = ct_scan_row( $blank['job_id'] ); + ct_test_assert( $br && get_post( $blank['request_id'] ) && 'fixture@example.com' === $br->contact_email && null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $blank['job_id'] ) ), "A job was (partly) deleted although its linked post's identity is {$kind}." ); + $wpdb->delete( $jobs, array( 'job_id' => $blank['job_id'] ) ); +} + +// Restored rows that cannot be acted on (held, leased) never starve a later restored row in the tombstone loop. +$restored_base = array( 'target_origin' => 'https://ret-starve.example.net/', 'scope_hash' => hash( 'sha256', 'https://ret-starve.example.net/' ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-4 years' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'status' => 'failed', 'created_at' => ct_test_old( '-4 years' ), 'retention_checked_at' => ct_scan_utc_now() ); +for ( $i = 0; $i < 56; $i++ ) { + $extra = 9 === $i % 10 ? array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+1 hour' ) ) : array( 'hold_reason' => 'Preserving this restored record for a dispute.', 'hold_until' => ct_test_old( '+30 days' ) ); + ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i ), $extra ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); + $wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 887000100 + $i, 'request_id' => 886000100 + $i, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +} +ct_test_assert( 1 === $wpdb->insert( $jobs, array_merge( $restored_base, array( 'job_id' => 887000200, 'request_id' => 886000200 ) ) ), 'Fixture insert of a restored row failed: ' . $wpdb->last_error ); +$wpdb->insert( ct_scan_tombstones_table(), array( 'job_id' => 887000200, 'request_id' => 886000200, 'scope_mac' => ct_scan_tombstone_mac( $restored_base['scope_hash'] ), 'created_at' => $restored_base['created_at'], 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => bin2hex( random_bytes( 16 ) ) ) ); +ct_scan_retention_run( true ); +$starve_left = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$jobs} WHERE job_id BETWEEN 887000100 AND 887000155" ); +ct_test_assert( ! ct_scan_row( 887000200 ) && 56 === $starve_left, "Held or leased restored rows starved a later restored row, or were deleted ($starve_left of 56 kept)." ); +$wpdb->query( "DELETE FROM {$jobs} WHERE job_id BETWEEN 887000100 AND 887000200" ); +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 887000100 AND 887000200' ); + + +// ---- Second round: floors, clock, ordering, locks ----------------------------------------------------------------- +// Evidence retention for non-legacy versions can never be configured below six years; legacy never below three. +foreach ( array( 'six', '', '0', '1', '5', '5.9', '-6', 'NULL', 'TRUE', 'FALSE', 'ARRAY', '1e400', 'NaN' ) as $raw ) { + $p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-years.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => $raw ) ) ); + $out = trim( stream_get_contents( $pp[1] ) ); + proc_close( $p ); + ct_test_assert( '2032-01-01 00:00:00 2028-01-01 00:00:00' === $out, "evidence_retention_years '$raw' gave '$out' instead of the six-year floor (legacy: three)." ); +} +$p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-years.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => '8' ) ) ); +ct_test_assert( '2034-01-01 00:00:00 2028-01-01 00:00:00' === trim( stream_get_contents( $pp[1] ) ), 'A longer configured retention was not honoured.' ); +proc_close( $p ); +// A retention period that would push the date past 2200 (where no date is trusted, so "kept forever") is clamped to 100 years. +$p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-years.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => '500' ) ) ); +$capped = trim( stream_get_contents( $pp[1] ) ); +proc_close( $p ); +ct_test_assert( '2126-01-01 00:00:00 2028-01-01 00:00:00' === $capped && 100 === ct_scan_clamp_years( 500, 6 ) && 100 === ct_scan_clamp_years( '1e10', 6 ) && 100 === ct_scan_clamp_years( 100, 6 ) && 99 === ct_scan_clamp_years( 99, 6 ), "An oversized retention period was not clamped to 100 years ($capped)." ); +ct_test_assert( 6 === ct_scan_clamp_years( 'six', 6 ) && 6 === ct_scan_clamp_years( null, 6 ) && 6 === ct_scan_clamp_years( 0, 6 ) && 6 === ct_scan_clamp_years( 1, 6 ) && 6 === ct_scan_clamp_years( true, 6 ) && 6 === ct_scan_clamp_years( array( 9 ), 6 ) && 7 === ct_scan_clamp_years( '7', 6 ) && 3 === ct_scan_clamp_years( 0, 1 ), 'The retention floors do not hold.' ); + +// A deletion is refused when the record appears to have closed before it was created (every other check passes). +$backwards = ct_test_aged_job( 'ret-backwards.example.net', 'failed', '-3 years -3 days', '-2 days' ); +$accepted_at = ct_test_old( '-3 years -3 days' ); +$wpdb->update( $jobs, array( 'terms_accepted_at' => $accepted_at, 'created_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+30 seconds' )->format( 'Y-m-d H:i:s' ), 'finished_at' => ( new DateTimeImmutable( $accepted_at, new DateTimeZone( 'UTC' ) ) )->modify( '+10 seconds' )->format( 'Y-m-d H:i:s' ) ), array( 'job_id' => $backwards['job_id'] ) ); +ct_test_make_due( $backwards ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $backwards ) && get_post( $backwards['request_id'] ), 'A record that appeared to close before it was created was deleted.' ); + +// Nothing is deleted while the clock is earlier than a stored job timestamp (a clock jumped backwards, a bad restore). +$clocked = ct_test_aged_job( 'ret-clock.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$future_row = ct_test_aged_job( 'ret-future-row.example.net', 'queued', '-1 day', null ); +$wpdb->update( $jobs, array( 'created_at' => ct_test_old( '+10 days' ), 'authorization_expires_at' => ct_test_old( '+60 days' ) ), array( 'job_id' => $future_row['job_id'] ) ); +$clock_closing = ct_test_aged_job( 'ret-clock-closing.example.net', 'cancelled', '-1 day', null ); // no finished_at: closure would stamp "now" +ct_test_maintain(); +ct_test_assert( ct_test_exists( $clocked ) && get_post( $clocked['request_id'] ), 'Evidence was deleted although the clock was earlier than a stored job timestamp.' ); +ct_test_assert( null === ct_scan_row( $clock_closing['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->closed_at && null === ct_scan_row( $clocked['job_id'] )->retain_until, 'A closure or deletion date was stamped while the clock could not be trusted.' ); +ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ) && ct_scan_clock_is_sane( ct_test_old( '+11 days' ) ), 'The clock sanity check does not compare with the newest stored timestamp.' ); +$wpdb->delete( $jobs, array( 'job_id' => $future_row['job_id'] ) ); +wp_delete_post( $future_row['request_id'], true ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $clocked ), 'Deletion did not resume once the clock was consistent.' ); +ct_test_assert( ! empty( ct_scan_row( $clock_closing['job_id'] )->closed_at ), 'Closure was not stamped once the clock was consistent.' ); +ct_test_assert( ! ct_scan_clock_is_sane( '1999-01-01 00:00:00' ) && ! ct_scan_clock_is_sane( '2300-01-01 00:00:00' ), 'Absurd clocks were accepted.' ); + +/** Stamp closure and the deletion date directly, without running a pass (the job is due as soon as retention runs). */ +function ct_test_make_due( $job ) { + global $wpdb; + $row = ct_scan_row( $job['job_id'] ); + $closed = $row->finished_at; + $row->closed_at = $closed; + $wpdb->update( ct_scan_jobs_table(), array( 'closed_at' => $closed, 'retain_until' => ct_scan_retain_until( $row ), 'retention_checked_at' => null ), array( 'job_id' => (int) $job['job_id'] ) ); +} + +// Retention does not hold the queue lock across its work: each deletion takes it for that job only, and +// never deletes without it. (Stop waits up to three seconds for this lock; it must not queue behind a batch.) +$batch2 = array(); +for ( $i = 0; $i < 12; $i++ ) { + $batch2[] = ct_test_aged_job( "ret-lockseq-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); +} +foreach ( $batch2 as $j ) { + ct_test_make_due( $j ); +} +$events = array(); +$lockname = static function ( $sql ) use ( &$events, $jobs ) { + if ( false !== strpos( $sql, "GET_LOCK('ctscan_" ) ) { $events[] = 'G'; } + elseif ( false !== strpos( $sql, "RELEASE_LOCK('ctscan_" ) ) { $events[] = 'R'; } + elseif ( 0 === strpos( ltrim( $sql ), "DELETE FROM `{$jobs}`" ) ) { $events[] = 'D'; } + return $sql; +}; +add_filter( 'query', $lockname ); +ct_scan_retention_run( true ); +remove_filter( 'query', $lockname ); +$depth = 0; $max_deletes_per_hold = 0; $in_hold = 0; $unlocked_deletes = 0; $holds = 0; +foreach ( $events as $e ) { + if ( 'G' === $e ) { ++$depth; ++$holds; $in_hold = 0; } + elseif ( 'R' === $e ) { --$depth; } + elseif ( 'D' === $e ) { if ( $depth < 1 ) { ++$unlocked_deletes; } ++$in_hold; $max_deletes_per_hold = max( $max_deletes_per_hold, $in_hold ); } +} +$deleted_now = 12 - count( array_filter( $batch2, 'ct_test_exists' ) ); +ct_test_assert( 12 === $deleted_now && 0 === $unlocked_deletes && $max_deletes_per_hold <= 1 && $holds >= 12 && 0 === $depth, "Retention locking is wrong (deleted $deleted_now, unlocked $unlocked_deletes, max per hold $max_deletes_per_hold, holds $holds)." ); + +// A busy queue means "later", not an error and not a long wait; a second retention run cannot overlap the first. +$busy = ct_test_aged_job( 'ret-busy.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $busy ); +$holder = proc_open( array( PHP_BINARY, __DIR__ . '/lock-holder.php', '1.5' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $hp, null, null ); +ct_test_assert( is_resource( $holder ) && "locked\n" === fgets( $hp[1] ), 'The queue-lock holder did not start.' ); +$started = microtime( true ); +ct_scan_retention_run( true ); +ct_test_assert( microtime( true ) - $started < 1.0 && ct_test_exists( $busy ), 'Retention waited for, or deleted under, a busy queue.' ); +proc_close( $holder ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $busy ), 'Retention did not delete once the queue was free.' ); +$busy2 = ct_test_aged_job( 'ret-busy2.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $busy2 ); +$holder = proc_open( array( PHP_BINARY, __DIR__ . '/lock-holder.php', '1.5', 'retention' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $hp, null, null ); +ct_test_assert( is_resource( $holder ) && "locked\n" === fgets( $hp[1] ), 'The retention-lock holder did not start.' ); +ct_test_assert( false === ct_scan_retention_run( true ) && ct_test_exists( $busy2 ), 'A second retention run overlapped the first.' ); +proc_close( $holder ); +ct_test_assert( true === ct_scan_retention_run( true ) && ! ct_test_exists( $busy2 ), 'Retention did not run once the other run finished.' ); +// Non-forced runs (the worker poll) are throttled to about one a minute. +delete_transient( 'ct_scan_retention_last' ); +ct_test_assert( true === ct_scan_retention_run() && false === ct_scan_retention_run(), 'Retention is not throttled on the polling path.' ); +delete_transient( 'ct_scan_retention_last' ); +// A Stop is not held up by retention: it takes the same queue lock but nothing holds it for long. +$stopper = ct_test_aged_job( 'ret-stop.example.net', 'awaiting_confirmation', '-1 hour', null ); +$wpdb->update( $jobs, array( 'confirmation_expires_at' => ct_test_old( '+1 day' ) ), array( 'job_id' => $stopper['job_id'] ) ); +$stop_token = bin2hex( random_bytes( 32 ) ); +$wpdb->update( $jobs, array( 'stop_token_hash' => hash( 'sha256', $stop_token ) ), array( 'job_id' => $stopper['job_id'] ) ); +$many = array(); +for ( $i = 0; $i < 20; $i++ ) { $many[] = ct_test_aged_job( "ret-stopbatch-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); ct_test_make_due( end( $many ) ); } +$started = microtime( true ); +ct_scan_retention_run( true ); +ct_test_assert( 'stopped' === ct_scan_customer_stop( $stopper['job_id'], $stop_token ) && microtime( true ) - $started < 3.0, 'A Stop around a retention run failed or took too long.' ); + +// Deletion that cannot complete leaves the record (and its tombstone) rather than half-deleting. +$stuck = ct_test_aged_job( 'ret-stuck.example.net', 'failed', '-3 years -1 day', '-1 day' ); +$block = static function ( $check, $post ) use ( $stuck ) { + return (int) $stuck['request_id'] === (int) $post->ID ? false : $check; +}; +add_filter( 'pre_delete_post', $block, 10, 2 ); +ct_test_maintain(); +remove_filter( 'pre_delete_post', $block, 10 ); +$s = ct_scan_row( $stuck['job_id'] ); +ct_test_assert( $s && get_post( $stuck['request_id'] ) && '' === $s->contact_email, 'A failed deletion lost the link between the job and its request.' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $stuck ) && ! get_post( $stuck['request_id'] ), 'The deletion was not completed on the next run.' ); + +// ---- Third review: extension instead of "never", clocks, shared budget, identities, leap days ---------------------- +// A longer period configured after closure: the earlier stored date is not honoured; retain_until is extended +// (never shortened) and the records are deleted when the later date arrives. +$run_raised = static function () { + $p = proc_open( array( PHP_BINARY, __DIR__ . '/retention-maintain.php' ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pp, null, array_merge( getenv(), array( 'CT_TEST_WP_ROOT' => getenv( 'CT_TEST_WP_ROOT' ) ?: '/var/www/html', 'CT_TEST_EVIDENCE_YEARS' => '8', 'CT_TEST_LEGACY_YEARS' => '5' ) ) ); + $out = trim( stream_get_contents( $pp[1] ) ); + $err = trim( stream_get_contents( $pp[2] ) ); + proc_close( $p ); + ct_test_assert( 'maintained' === $out, "The raised-configuration maintenance run failed: $out $err" ); + wp_cache_flush(); // the other process changed the database behind this one's object cache +}; +// (Dates before 2020 are refused as untrustworthy, so the case whose new date has already come is a legacy one.) +$raise_past = ct_test_aged_job( 'ret-raise-past.example.net', 'failed', '-5 years -3 days', '-5 years -2 days' ); +$raise_future = ct_test_aged_job( 'ret-raise-future.example.net', 'cancelled', '-6 years -3 days', '-6 years -2 days', '2027-06-01-r3' ); +$raise_legacy = ct_test_aged_job( 'ret-raise-legacy.example.net', 'failed', '-3 years -2 days', '-1 day' ); +foreach ( array( $raise_past, $raise_future, $raise_legacy ) as $j ) { + ct_test_make_due( $j ); // stamped at closure under six (legacy: three) years: due now +} +$plus = static function ( $date, $years ) { + return ( new DateTimeImmutable( $date, new DateTimeZone( 'UTC' ) ) )->modify( '+' . $years . ' years' )->format( 'Y-m-d H:i:s' ); +}; +$run_raised(); +$rp = ct_scan_row( $raise_past['job_id'] ); +$rf = ct_scan_row( $raise_future['job_id'] ); +$rl = ct_scan_row( $raise_legacy['job_id'] ); +ct_test_assert( $rp && $rf && $rl && get_post( $raise_past['request_id'] ) && get_post( $raise_future['request_id'] ) && get_post( $raise_legacy['request_id'] ), 'Evidence was deleted at the date fixed under a shorter configuration.' ); +ct_test_assert( $rp->retain_until === $plus( $rp->terms_accepted_at, 5 ) && $rf->retain_until === $plus( $rf->closed_at, 8 ) && $rl->retain_until === $plus( $rl->terms_accepted_at, 5 ), "retain_until was not extended to the longer configured period ({$rp->retain_until} {$rf->retain_until} {$rl->retain_until}; checked {$rp->retention_checked_at})." ); +$run_raised(); +ct_test_assert( ! ct_test_exists( $raise_past ) && ! get_post( $raise_past['request_id'] ), 'Evidence was not deleted once the extended date arrived.' ); +ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ), 'Evidence was deleted before its extended date.' ); +// Back under the shorter configuration, the extended dates are not shortened again. +ct_test_maintain(); +ct_test_assert( ct_test_exists( $raise_future ) && ct_test_exists( $raise_legacy ) && $rf->retain_until === ct_scan_row( $raise_future['job_id'] )->retain_until && $rl->retain_until === ct_scan_row( $raise_legacy['job_id'] )->retain_until, 'An extended deletion date was shortened when the configuration went back.' ); + +// Leap days: a period that starts on 29 February ends on 1 March, never on 28 February. +$leap_r2 = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2026-10-21-r2', 'terms_accepted_at' => '2024-02-29 12:00:00', 'created_at' => '2024-02-29 12:00:30', 'closed_at' => '2024-03-02 00:00:00', 'retain_until' => '2027-03-01 12:00:00' ); +$leap_new = (object) array( 'status' => 'failed', 'identity' => str_repeat( 'a', 32 ), 'hold_reason' => null, 'hold_until' => null, 'terms_version' => '2027-06-01-r3', 'terms_accepted_at' => '2024-02-20 12:00:00', 'created_at' => '2024-02-20 12:00:30', 'closed_at' => '2024-02-29 12:00:00', 'retain_until' => '2030-03-01 12:00:00' ); +ct_test_assert( '2027-03-01 12:00:00' === ct_scan_retain_until( $leap_r2 ) && '2027-03-01 12:00:00' === ct_scan_required_retain_until( $leap_r2 ) && '2030-03-01 12:00:00' === ct_scan_retain_until( $leap_new ), 'A period starting on 29 February does not end on 1 March.' ); +ct_test_assert( ! ct_scan_deletion_allowed( $leap_r2, '2027-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_r2, '2027-03-01 12:00:00' ) && ! ct_scan_deletion_allowed( $leap_new, '2030-02-28 23:59:59' ) && ct_scan_deletion_allowed( $leap_new, '2030-03-01 12:00:00' ), 'A leap-day period allowed deletion early (or never).' ); +$leap_r2->retain_until = '2027-02-28 12:00:00'; +ct_test_assert( array( 'extend', '2027-03-01 12:00:00' ) === ct_scan_deletion_decision( $leap_r2, '2027-03-05 00:00:00' ), 'A stored 28 February date for a 29 February acceptance was not extended to 1 March.' ); + +// The PHP and database clocks disagreeing by more than five minutes (either way) stops deletions; logged once. +$skewed = ct_test_aged_job( 'ret-skew.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $skewed ); +$log = tempnam( sys_get_temp_dir(), 'ctret' ); +$previous_log = ini_set( 'error_log', $log ); +$wpdb->query( 'SET @@session.timestamp = ' . ( time() + 600 ) ); +ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ), 'A database clock ten minutes ahead of PHP was accepted.' ); +ct_test_maintain(); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $skewed ) && get_post( $skewed['request_id'] ), 'Evidence was deleted while the PHP and database clocks disagreed.' ); +$wpdb->query( 'SET @@session.timestamp = ' . ( time() - 600 ) ); +ct_test_assert( ! ct_scan_clock_is_sane( ct_scan_utc_now() ), 'A database clock ten minutes behind PHP was accepted.' ); +$wpdb->query( 'SET @@session.timestamp = DEFAULT' ); +ct_test_assert( ct_scan_clock_is_sane( ct_scan_utc_now() ), 'Agreeing clocks were refused.' ); +$clock_logged = substr_count( (string) file_get_contents( $log ), 'retention deletions skipped' ); +ct_test_maintain(); +ini_set( 'error_log', (string) $previous_log ); +@unlink( $log ); +ct_test_assert( 1 === $clock_logged, "The clock problem was logged $clock_logged times over two runs instead of once." ); +ct_test_assert( ! ct_test_exists( $skewed ) && false === get_option( 'ct_scan_retention_clock_warned' ), 'Deletion did not resume, or the warning was not reset, once the clocks agreed.' ); + +// A due job whose linked post carries another record's identity is kept whole (no tombstone, no scrub). +$foreign = ct_test_aged_job( 'ret-foreign.example.net', 'failed', '-3 years -1 day', '-1 day' ); +update_post_meta( $foreign['request_id'], '_ct_scan_identity', str_repeat( 'b', 32 ) ); +ct_test_maintain(); +$fr = ct_scan_row( $foreign['job_id'] ); +ct_test_assert( $fr && get_post( $foreign['request_id'] ) && 'fixture@example.com' === $fr->contact_email && null === $wpdb->get_var( $wpdb->prepare( 'SELECT job_id FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $foreign['job_id'] ) ), 'A job was (partly) deleted although its linked post belongs to another record.' ); +// A restored job row whose request id now belongs to another post (a recycled id): the row is deleted again, the post is kept. +$recycled = ct_test_aged_job( 'ret-recycled.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $recycled ); +$saved = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE job_id = %d", $recycled['job_id'] ), ARRAY_A ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $recycled ) && ! get_post( $recycled['request_id'] ), 'Fixture expected the recycled-id job to be deleted.' ); +$saved['retention_checked_at'] = ct_scan_utc_now(); // examined just now: only the tombstone loop reaches it +$wpdb->insert( $jobs, $saved ); +wp_insert_post( array( 'import_id' => $recycled['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'another record' ) ); +update_post_meta( $recycled['request_id'], '_ct_scan_identity', str_repeat( 'e', 32 ) ); +ct_test_assert( ct_test_exists( $recycled ) && get_post( $recycled['request_id'] ), 'Fixture failed to restore the row and recycle the post id.' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $recycled ) && get_post( $recycled['request_id'] ), 'A restored row survived, or its tombstone deleted a post that belongs to another record.' ); +wp_delete_post( $recycled['request_id'], true ); + +// One budget of 50 deletion attempts per run, shared by the evidence step and both tombstone loops. +ct_test_maintain(); +$ghost_jobs = array(); +$ghost_rows = array(); +for ( $i = 0; $i < 10; $i++ ) { + $g = ct_test_aged_job( "ret-budget-ghost-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( $g ); + $ghost_rows[] = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE job_id = %d", $g['job_id'] ), ARRAY_A ); + $ghost_jobs[] = $g; +} +ct_test_maintain(); +ct_test_assert( 0 === count( array_filter( $ghost_jobs, 'ct_test_exists' ) ), 'Fixture expected the budget ghosts to be deleted.' ); +foreach ( $ghost_rows as $g ) { + $g['retention_checked_at'] = ct_scan_utc_now(); // examined just now: only the tombstone loop reaches these + $wpdb->insert( $jobs, $g ); +} +$budget_jobs = array(); +for ( $i = 0; $i < 45; $i++ ) { + $budget_jobs[] = ct_test_aged_job( "ret-budget-$i.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_make_due( end( $budget_jobs ) ); +} +ct_scan_retention_run( true ); +$left = count( array_filter( $ghost_jobs, 'ct_test_exists' ) ) + count( array_filter( $budget_jobs, 'ct_test_exists' ) ); +ct_test_assert( $left >= 5 && $left < 55, "One run did not keep to a single budget of 50 deletions ($left of 55 remain)." ); +for ( $run = 0; $run < 4 && $left > 0; $run++ ) { + ct_test_maintain(); + $left = count( array_filter( $ghost_jobs, 'ct_test_exists' ) ) + count( array_filter( $budget_jobs, 'ct_test_exists' ) ); +} +ct_test_assert( 0 === $left, "Later runs did not finish the remaining deletions ($left remain)." ); + +// A worker poll that is handed a job does not wait for retention; a poll with nothing to hand out runs it. +$wpdb->update( $jobs, array( 'status' => 'cancelled', 'finished_at' => ct_scan_utc_now() ), array( 'job_id' => $live['job_id'] ) ); // the only other queued job here +$claim_due = ct_test_aged_job( 'ret-claim-due.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $claim_due ); +list( $claim_request, $claim_fields ) = ct_test_request( 'ret-claim.example.net' ); +$request_ids[] = $claim_request; +$claim_event = ct_scan_on_request_ready( $claim_request, $claim_fields ); +ct_test_queue_job( $claim_event['job_id'] ); +delete_transient( 'ct_scan_retention_last' ); +$claimed = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data(); +ct_test_assert( is_array( $claimed['job'] ) && ct_test_exists( $claim_due ), 'A claim that handed out a job ran retention first (or no job was handed out).' ); +delete_transient( 'ct_scan_retention_last' ); +$idle = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data(); +ct_test_assert( null === $idle['job'] && ! ct_test_exists( $claim_due ), 'A claim with nothing to hand out did not run retention.' ); +ct_test_end_job( (int) $claimed['job']['id'] ); +delete_transient( 'ct_scan_retention_last' ); + +// ---- Target control: a job waiting for (or in the middle of) a control check never closes ----------------------- +foreach ( array( 'pending' => null, 'checking' => '+2 minutes' ) as $state => $lease_for ) { + $ctl = ct_test_aged_job( "ret-control-{$state}.example.net", 'failed', '-3 years -1 day', '-1 day' ); + $wpdb->update( $jobs, array( 'status' => 'awaiting_control', 'control_required' => 1, 'control_state' => $state, 'control_attempts' => 1, 'control_lease_hash' => $lease_for ? str_repeat( 'c', 64 ) : null, 'control_lease_until' => $lease_for ? ct_test_old( $lease_for ) : null, 'authorization_expires_at' => ct_test_old( '+30 days' ), 'finished_at' => null, 'closed_at' => ct_test_old( '-4 years' ), 'retain_until' => ct_test_old( '-1 day' ) ), array( 'job_id' => $ctl['job_id'] ) ); + ct_test_maintain(); + $cr = ct_scan_row( $ctl['job_id'] ); + ct_test_assert( $cr && 'awaiting_control' === $cr->status && $state === $cr->control_state && null === $cr->closed_at && null === $cr->retain_until && get_post( $ctl['request_id'] ), "A job awaiting target control ({$state}) was closed or deleted by retention." ); + $wpdb->delete( $jobs, array( 'job_id' => $ctl['job_id'] ) ); +} +// A restored row that a tombstone recognises is not deleted while it holds a lease (a control check in progress or a +// running scan): that would free the one-at-a-time gate while its traffic may be in flight. Once the lease ends, it is. +foreach ( array( 'control' => array( 'status' => 'awaiting_control', 'control_required' => 1, 'control_state' => 'checking', 'control_attempts' => 1, 'control_lease_hash' => str_repeat( 'c', 64 ), 'control_lease_until' => ct_test_old( '+2 minutes' ) ), 'scan' => array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => str_repeat( 'b', 64 ), 'lease_until' => ct_test_old( '+2 minutes' ) ) ) as $kind => $leased ) { + $gone = ct_test_aged_job( "ret-restore-{$kind}.example.net", 'failed', '-3 years -1 day', '-1 day' ); + ct_test_maintain(); + ct_test_assert( ! ct_test_exists( $gone ), 'Fixture expected the job to be deleted first.' ); + $gstone = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $gone['job_id'] ) ); + $wpdb->insert( $jobs, array_merge( array( 'job_id' => $gone['job_id'], 'request_id' => $gone['request_id'], 'target_origin' => "https://ret-restore-{$kind}.example.net/", 'scope_hash' => hash( 'sha256', "https://ret-restore-{$kind}.example.net/" ), 'terms_version' => '2026-10-21-r2', 'terms_hash' => str_repeat( 'a', 64 ), 'terms_accepted_at' => ct_test_old( '-3 years -1 day' ), 'authorization_expires_at' => ct_test_old( '+30 days' ), 'contact_email' => 'restored@example.com', 'contact_name' => 'Restored', 'referrer' => 'direct', 'created_at' => $gstone->created_at ), $leased ) ); + ct_test_maintain(); + ct_test_assert( ct_test_exists( $gone ), "A restored row holding a live {$kind} lease was deleted by its tombstone mid-lease." ); + $wpdb->update( $jobs, 'control' === $kind ? array( 'control_lease_until' => ct_test_old( '-1 minute' ) ) : array( 'lease_until' => ct_test_old( '-1 minute' ) ), array( 'job_id' => $gone['job_id'] ) ); + ct_test_maintain(); + ct_test_assert( ! ct_test_exists( $gone ), "A restored row was not deleted again after its {$kind} lease ended." ); +} + +// ---- Fifth review: stale release, identity backfill ownership, restored-post paging ---------------------------- +wp_set_current_user( 1 ); +// A stale "Release hold" form (for hold H1) cannot end a newer hold H2 placed after H1 was released. +$stale = ct_test_aged_job( 'ret-stale-release.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $stale ); +ct_test_assert( true === ct_scan_admin_set_hold( $stale['job_id'], 'First hold H1 for dispute number 1357.', 10 ), 'Fixture hold H1 was refused.' ); +$h1_identity = ct_scan_hold_identity( ct_scan_row( $stale['job_id'] ) ); +ct_test_assert( true === ct_scan_admin_release_hold( $stale['job_id'], $h1_identity ), 'Releasing H1 failed.' ); +ct_test_assert( true === ct_scan_admin_set_hold( $stale['job_id'], 'Second hold H2 for litigation number 2468.', 730 ), 'Fixture hold H2 was refused.' ); +$h2 = ct_scan_row( $stale['job_id'] ); +$replayed = ct_scan_admin_release_hold( $stale['job_id'], $h1_identity ); +$h2_now = ct_scan_row( $stale['job_id'] ); +ct_test_assert( is_wp_error( $replayed ) && 'hold_changed' === $replayed->get_error_code() && $h2->hold_until === $h2_now->hold_until && $h2->hold_reason === $h2_now->hold_reason && null === $h2_now->hold_released_at && null === $h2_now->hold_released_by, 'A stale release form for an earlier hold released a newer hold.' ); +ct_test_maintain(); +ct_test_assert( ct_test_exists( $stale ) && get_post( $stale['request_id'] ), 'A due job under a newer hold was deleted after a stale release.' ); +ct_test_assert( true === ct_scan_admin_release_hold( $stale['job_id'], ct_scan_hold_identity( $h2_now ) ), 'The current hold could not be released with its own identity.' ); +$wpdb->delete( $jobs, array( 'job_id' => $stale['job_id'] ) ); + +// The identity backfill never adopts or overwrites the identity of a request post that names another job. +$owner = ct_test_aged_job( 'ret-backfill-owner.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_make_due( $owner ); +$wpdb->update( $jobs, array( 'identity' => null ), array( 'job_id' => $owner['job_id'] ) ); +update_post_meta( $owner['request_id'], '_ct_scan_job_id', 999999998 ); +update_post_meta( $owner['request_id'], '_ct_scan_identity', str_repeat( 'c', 32 ) ); +ct_test_maintain(); +ct_test_assert( null === ct_scan_row( $owner['job_id'] )->identity && str_repeat( 'c', 32 ) === ct_scan_meta_string( $owner['request_id'], '_ct_scan_identity' ) && ct_test_exists( $owner ) && get_post( $owner['request_id'] ), 'The identity backfill adopted, or the job lost records to, a request post that names another job.' ); +delete_post_meta( $owner['request_id'], '_ct_scan_identity' ); +ct_test_maintain(); +ct_test_assert( null === ct_scan_row( $owner['job_id'] )->identity && '' === ct_scan_meta_string( $owner['request_id'], '_ct_scan_identity' ) && ct_test_exists( $owner ), 'The identity backfill wrote an identity onto a request post that names another job.' ); +update_post_meta( $owner['request_id'], '_ct_scan_job_id', $owner['job_id'] ); +update_post_meta( $owner['request_id'], '_ct_scan_identity', str_repeat( 'c', 32 ) ); +ct_test_maintain(); +ct_test_assert( str_repeat( 'c', 32 ) === $wpdb->get_var( $wpdb->prepare( 'SELECT identity FROM ' . ct_scan_tombstones_table() . ' WHERE job_id = %d', $owner['job_id'] ) ) && ! ct_test_exists( $owner ), 'Once the request named its job, the backfill did not adopt its identity (and allow the due deletion).' ); + +// Restored request posts whose deletion is refused, or that name another job, never starve an older restored post. +$stone_row = static function ( $job_id, $post_id, $identity ) { + return array( 'job_id' => $job_id, 'request_id' => $post_id, 'scope_mac' => str_repeat( 'd', 64 ), 'created_at' => ct_test_old( '-2 years' ), 'deleted_at' => ct_test_old( '-1 day' ), 'reason' => 'retention', 'identity' => $identity ); +}; +$restored_post = static function ( $job_id, $identity ) { + $id = wp_insert_post( array( 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); + update_post_meta( $id, '_ct_scan_job_id', $job_id ); + update_post_meta( $id, '_ct_scan_identity', $identity ); + return (int) $id; +}; +$target_identity = bin2hex( random_bytes( 16 ) ); +$target_post = $restored_post( 887100000, $target_identity ); +ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), $stone_row( 887100000, $target_post, $target_identity ) ), 'Fixture tombstone insert failed: ' . $wpdb->last_error ); +$target_stone = (int) $wpdb->insert_id; +$refused_posts = array(); +$foreign_posts = array(); +for ( $i = 1; $i <= 55; $i++ ) { + $identity = bin2hex( random_bytes( 16 ) ); + $refused_posts[] = $restored_post( 887100000 + $i, $identity ); + ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), $stone_row( 887100000 + $i, end( $refused_posts ), $identity ) ), 'Fixture tombstone insert failed: ' . $wpdb->last_error ); + // Same identity as its tombstone, but the post names another job: never a candidate. + $identity = bin2hex( random_bytes( 16 ) ); + $foreign_posts[] = $restored_post( 887100100 + $i, $identity ); + update_post_meta( end( $foreign_posts ), '_ct_scan_job_id', 999999997 ); + ct_test_assert( 1 === $wpdb->insert( ct_scan_tombstones_table(), $stone_row( 887100100 + $i, end( $foreign_posts ), $identity ) ), 'Fixture tombstone insert failed: ' . $wpdb->last_error ); +} +$refuse = static function ( $check, $post ) use ( $refused_posts ) { + return in_array( (int) $post->ID, $refused_posts, true ) ? false : $check; +}; +add_filter( 'pre_delete_post', $refuse, 10, 2 ); +update_option( 'ct_scan_restored_post_cursor', (string) $target_stone, false ); // start just past the older post: it is reached only by wrapping around +ct_scan_apply_tombstones( ct_scan_utc_now() ); +ct_scan_apply_tombstones( ct_scan_utc_now() ); +remove_filter( 'pre_delete_post', $refuse, 10 ); +$refused_left = count( array_filter( $refused_posts, 'get_post' ) ); +$foreign_left = count( array_filter( $foreign_posts, 'get_post' ) ); +ct_test_assert( ! get_post( $target_post ) && 55 === $refused_left && 55 === $foreign_left, "Refused or foreign restored posts starved an older restored post, or a foreign post was deleted ($refused_left refused, $foreign_left foreign of 55 kept)." ); +foreach ( array_merge( $refused_posts, $foreign_posts ) as $id ) { + wp_delete_post( $id, true ); +} +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() . ' WHERE job_id BETWEEN 887100000 AND 887100200' ); + +// ---- Sixth review: collations, failed queries, a failed deletion under a raised period --------------------------- +// A tombstones table whose collation differs from the post meta's (utf8mb4_unicode_ci post meta beside a +// utf8mb4_unicode_520_ci tombstones table, say) must not make the restored-post comparison fail silently. +$stones_table = ct_scan_tombstones_table(); +$column_info = static function ( $table, $column ) use ( $wpdb ) { + return $wpdb->get_row( $wpdb->prepare( 'SELECT CHARACTER_SET_NAME AS charset, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s', $table, $column ) ); +}; +$meta_column = $column_info( $wpdb->postmeta, 'meta_value' ); +$identity_column = $column_info( $stones_table, 'identity' ); +ct_test_assert( $meta_column && $identity_column && $meta_column->charset && $identity_column->collation, 'Fixture could not read the column collations.' ); +$other_collation = null; +foreach ( array( '_general_ci', '_unicode_ci', '_unicode_520_ci' ) as $suffix ) { + $candidate = $meta_column->charset . $suffix; + if ( $candidate !== $meta_column->collation && $wpdb->get_var( $wpdb->prepare( 'SELECT COLLATION_NAME FROM information_schema.COLLATIONS WHERE COLLATION_NAME = %s', $candidate ) ) ) { + $other_collation = $candidate; + break; + } +} +ct_test_assert( null !== $other_collation, 'Fixture found no second collation for ' . $meta_column->charset . '.' ); +$collated = ct_test_aged_job( 'ret-collation.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $collated ) && ! get_post( $collated['request_id'] ), 'Fixture expected the collation job to be deleted.' ); +$collated_stone = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$stones_table} WHERE job_id = %d", $collated['job_id'] ) ); +try { + ct_test_assert( false !== $wpdb->query( "ALTER TABLE {$stones_table} MODIFY identity char(32) CHARACTER SET {$meta_column->charset} COLLATE {$other_collation} NULL" ), 'Fixture could not change the identity collation: ' . $wpdb->last_error ); + // The fixture really reproduces the problem: a plain comparison across the two columns is refused. + $suppressed = $wpdb->suppress_errors( true ); + $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} m INNER JOIN {$stones_table} t ON m.meta_value = t.identity" ); + $plain_error = $wpdb->last_error; + $wpdb->suppress_errors( $suppressed ); + ct_test_assert( '' !== $plain_error, "Fixture expected {$meta_column->collation} against {$other_collation} to be an illegal mix of collations." ); + wp_insert_post( array( 'import_id' => $collated['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); + update_post_meta( $collated['request_id'], '_ct_scan_job_id', $collated['job_id'] ); + update_post_meta( $collated['request_id'], '_ct_scan_identity', $collated_stone->identity ); + delete_option( 'ct_scan_retention_query_warned' ); + ct_test_maintain(); + ct_test_assert( ! get_post( $collated['request_id'] ) && empty( $GLOBALS['ct_scan_retention_query_failure'] ) && ! get_option( 'ct_scan_retention_query_warned' ), "A restored request was not deleted again when the tombstones table uses another collation ({$other_collation}) than the post meta ({$meta_column->collation})." ); +} finally { + $wpdb->query( "ALTER TABLE {$stones_table} MODIFY identity char(32) CHARACTER SET {$identity_column->charset} COLLATE {$identity_column->collation} NULL" ); +} +ct_test_assert( $identity_column->collation === $column_info( $stones_table, 'identity' )->collation, 'Fixture could not restore the identity collation.' ); + +// A failed retention query is never read as "nothing to do" or "no live record": the records are kept, the failure +// is logged once, and the next clean run does the work and forgets the warning. +$failing = ct_test_aged_job( 'ret-failing-query.example.net', 'failed', '-3 years -1 day', '-1 day' ); +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $failing ), 'Fixture expected the failing-query job to be deleted.' ); +$failing_stone = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$stones_table} WHERE job_id = %d", $failing['job_id'] ) ); +wp_insert_post( array( 'import_id' => $failing['request_id'], 'post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'Integration fixture request', 'post_content' => 'restored' ) ); +update_post_meta( $failing['request_id'], '_ct_scan_job_id', $failing['job_id'] ); +update_post_meta( $failing['request_id'], '_ct_scan_identity', $failing_stone->identity ); +foreach ( array( 'candidates' => "'_ct_scan_identity'", 'live lookup' => ' OR job_id = ' ) as $kind => $marker ) { + $break_query = static function ( $query ) use ( $marker, $jobs ) { + $is_target = ' OR job_id = ' === $marker ? 0 === strpos( $query, "SELECT job_id FROM {$jobs} WHERE request_id = " ) : ( false !== strpos( $query, 'INNER JOIN' ) && false !== strpos( $query, $marker ) ); + return $is_target ? $query . ' AND (' : $query; // a syntax error + }; + delete_option( 'ct_scan_retention_query_warned' ); + add_filter( 'query', $break_query ); + $suppressed = $wpdb->suppress_errors( true ); + ct_test_maintain(); + $wpdb->suppress_errors( $suppressed ); + remove_filter( 'query', $break_query ); + ct_test_assert( get_post( $failing['request_id'] ) && 'tombstone_posts' === get_option( 'ct_scan_retention_query_warned' ), "A failed restored-post query ({$kind}) was treated as no candidates or no live record, or was not logged." ); +} +ct_test_maintain(); +ct_test_assert( ! get_post( $failing['request_id'] ) && ! get_option( 'ct_scan_retention_query_warned' ), 'After the query recovered, the restored request was not deleted or the warning was not cleared.' ); + +// A live row whose retention deletion wrote its tombstone and then failed part-way is not deleted by that tombstone +// before the deadline of a retention period raised since (holds aside, the tombstone loop applies the same deadline). +$partial = ct_test_aged_job( 'ret-partial.example.net', 'cancelled', '-6 years -3 days', '-6 years -2 days', '2027-06-01-r3' ); +ct_test_make_due( $partial ); +$block_partial = static function ( $check, $post ) use ( $partial ) { + return (int) $partial['request_id'] === (int) $post->ID ? false : $check; +}; +add_filter( 'pre_delete_post', $block_partial, 10, 2 ); +ct_test_maintain(); +remove_filter( 'pre_delete_post', $block_partial, 10 ); +$pr = ct_scan_row( $partial['job_id'] ); +ct_test_assert( $pr && get_post( $partial['request_id'] ) && null !== $wpdb->get_var( $wpdb->prepare( "SELECT job_id FROM {$stones_table} WHERE job_id = %d", $partial['job_id'] ) ), 'Fixture expected a deletion that wrote its tombstone and then failed.' ); +// Examined just now, so the evidence step leaves it alone: only the tombstone loop decides in the next run. +$wpdb->update( $jobs, array( 'retention_checked_at' => ct_scan_utc_now() ), array( 'job_id' => $partial['job_id'] ) ); +$run_raised(); // eight years from closure: not due for almost two years +ct_test_assert( ct_test_exists( $partial ) && get_post( $partial['request_id'] ) && $pr->retain_until === ct_scan_row( $partial['job_id'] )->retain_until, 'A tombstone deleted a live row (whose deletion had failed) before the deadline of a period raised since.' ); +// Under the period in force when it was due, the tombstone loop completes the deletion. +ct_test_maintain(); +ct_test_assert( ! ct_test_exists( $partial ) && ! get_post( $partial['request_id'] ), 'The tombstone loop did not complete a failed deletion whose deadline has passed.' ); + +foreach ( $request_ids as $id ) { + $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) ); + wp_delete_post( $id, true ); +} +$wpdb->query( 'DELETE FROM ' . ct_scan_tombstones_table() ); +echo "Retention checks passed.\n"; diff --git a/integrations/wordpress/tests/support.php b/integrations/wordpress/tests/support.php index 668c6cc..6d0efd6 100644 --- a/integrations/wordpress/tests/support.php +++ b/integrations/wordpress/tests/support.php @@ -19,6 +19,10 @@ 'confirmation_ttl_hours' => 24, 'global_daily_limit' => 1000, 'target_daily_limit' => 100, + // CT_TEST_EVIDENCE_YEARS: a raw value (words, NULL, TRUE, ARRAY, numbers) for evidence_retention_years. + 'evidence_retention_years' => false === getenv( 'CT_TEST_EVIDENCE_YEARS' ) ? 6 : ( array( 'NULL' => null, 'TRUE' => true, 'FALSE' => false, 'ARRAY' => array( 9 ) )[ getenv( 'CT_TEST_EVIDENCE_YEARS' ) ] ?? getenv( 'CT_TEST_EVIDENCE_YEARS' ) ), + // CT_TEST_LEGACY_YEARS: legacy_retention_years for r2-and-earlier acceptances (default three). + 'legacy_retention_years' => false === getenv( 'CT_TEST_LEGACY_YEARS' ) ? 3 : getenv( 'CT_TEST_LEGACY_YEARS' ), // CT_TEST_POLICY: legacy (default; the grandfathered r2 flow), strict, or none (no decision declared). 'terms_policy' => 'none' === getenv( 'CT_TEST_POLICY' ) ? array() : array( ( getenv( 'CT_TEST_VERSION' ) ?: '2026-10-21-r2' ) => 'strict' === getenv( 'CT_TEST_POLICY' ) ? 'strict' : 'legacy' ), 'send_accepted_copy' => '1' === getenv( 'CT_TEST_COPY' ), @@ -203,3 +207,9 @@ function ct_test_end_job( $job_id ) { global $wpdb; $wpdb->update( ct_scan_jobs_table(), array( 'status' => 'failed', 'outcome' => 'failed', 'finished_at' => ct_scan_utc_now(), 'lease_hash' => null, 'lease_until' => null ), array( 'job_id' => (int) $job_id ) ); } + +/** One maintenance pass as production does it: state transitions under the queue lock, then retention outside it. */ +function ct_test_maintain() { + ct_scan_maintenance_locked(); + ct_scan_retention_run( true ); +}