Skip to content

fix(avif)!: scope AVIF sibling filenames by attachment ID to prevent collisions - #289

Merged
marcoluzi merged 1 commit into
mainfrom
fix/avif-sibling-path-collision
Aug 24, 2026
Merged

marcoluzi merged 1 commit into
mainfrom
fix/avif-sibling-path-collision

Conversation

@marcoluzi

@marcoluzi marcoluzi commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes a production bug where two WordPress attachments sharing a basename across different extensions (e.g. siegel.png and siegel.jpg) collided on the same generated AVIF sibling path. Root cause and fix details below.

Root cause

AvifVariantGenerator, AvifCleanup, and FocalPointCropper each independently derived AVIF sibling filenames from the source file's basename alone (image-300x200.jpg -> image-300x200.avif). WordPress legitimately allows two different attachments to share a basename as long as the extension differs (wp_unique_filename() only dedupes same-extension collisions), so two unrelated attachments could resolve to the identical AVIF path.

This produced two symptoms reported from a live site:

  1. 0-byte / corrupted AVIF file. AvifVariantGenerator::generate() writes directly to the shared path with no locking or atomic rename. Two requests resolving different attachments could race on the same file, leaving it truncated for whichever attachment read it next.
  2. Cross-attachment deletion. On delete_attachment, AvifCleanup unlinks the sibling it derives from the deleted attachment's own file, with no check that the file actually belongs to that attachment. Deleting siegel.jpg removed the AVIF sibling siegel.png depended on.

Fix

Centralized sibling-path derivation in a new AvifSiblingPath::for(string $file, int $attachmentId): ?string, which scopes the filename by attachment ID: image-300x200.jpg -> image-300x200.{attachmentId}.avif. Wired it into the three previous call sites (all of them already had the attachment ID in scope, so no new plumbing was needed):

  • AvifVariantGenerator::ensure()
  • AvifCleanup::doForget()
  • FocalPointCropper::purgeAvifSibling()

Added regression coverage: two attachments sharing a basename now get distinct AVIF paths, and deleting one never touches the other's sibling. Updated specs/avif-conversion.md with the collision-scoping rule and a new Gherkin scenario.

BREAKING CHANGE

AVIF siblings generated before this change used the old basename-only naming (image-300x200.avif). After upgrading, the resolver only ever looks up the new attachment-ID-scoped name, so:

  • Every previously-generated AVIF sibling becomes an orphan on disk. Not corrupted, not unsafe to leave in place, just unused space.
  • Every image lazily regenerates its AVIF sibling under the new name on next resolution, bounded by the existing per-request generation cap, with the original <img> as fallback in the meantime. No broken images during the transition.

This is not a PHP API break (no public method signatures changed), but it does mean sites with AVIF enabled will temporarily double their AVIF storage until the old files are cleaned up.

Cleanup command

Run this once per site after upgrading to reclaim the disk space used by orphaned pre-upgrade AVIF files. It only touches files that are legacy-named siblings of a real, current WordPress attachment (original file or a registered subsize), so it will not touch anything that isn't a stale Sproutset-generated sibling.

Save as cleanup-legacy-avif.php in the site root and run with WP-CLI:

<?php
/**
 * One-off cleanup for AVIF siblings generated by Sproutset before AVIF
 * sibling filenames were scoped by attachment ID. Safe to delete: the
 * resolver only ever looks up the new, attachment-ID-scoped name after
 * upgrading, so these files are never read again.
 *
 * Usage:
 *   wp eval-file cleanup-legacy-avif.php dry-run   # list only
 *   wp eval-file cleanup-legacy-avif.php           # delete
 *
 */

$dryRun = in_array('dry-run', $args, true);
$seen = [];
$removed = 0;
$bytes = 0;
$paged = 1;

do {
    $query = new WP_Query([
        'post_type' => 'attachment',
        'post_status' => 'inherit',
        'post_mime_type' => 'image',
        'posts_per_page' => 200,
        'paged' => $paged,
        'fields' => 'ids',
        'no_found_rows' => true,
    ]);

    foreach ($query->posts as $attachmentId) {
        $file = get_attached_file($attachmentId);

        if ($file === false) {
            continue;
        }

        $directory = dirname($file);
        $targets = [$file];

        $metadata = wp_get_attachment_metadata($attachmentId);
        foreach ((array) ($metadata['sizes'] ?? []) as $size) {
            if (is_array($size) && isset($size['file']) && is_string($size['file'])) {
                $targets[] = $directory.'/'.$size['file'];
            }
        }

        foreach ($targets as $target) {
            $extension = pathinfo($target, PATHINFO_EXTENSION);

            if ($extension === '' || $extension === 'avif') {
                continue;
            }

            $legacy = substr($target, 0, -strlen($extension)).'avif';

            if (isset($seen[$legacy]) || ! is_file($legacy)) {
                continue;
            }

            $seen[$legacy] = true;
            $size = filesize($legacy);
            $bytes += $size === false ? 0 : $size;
            $removed++;

            WP_CLI::log($legacy);

            if (! $dryRun) {
                unlink($legacy);
            }
        }
    }

    $paged++;
} while ($query->posts !== []);

WP_CLI::success(sprintf(
    '%s %d legacy AVIF file(s), %s.',
    $dryRun ? 'Would remove' : 'Removed',
    $removed,
    size_format($bytes),
));

Run the dry-run pass first and eyeball the file list before deleting. Delete the script after use; it is not part of the package.

Verification

  • vendor/bin/pint --dirty clean
  • composer types:check (PHPStan/Larastan, level max) clean
  • composer test (81 unit/feature tests) passing
  • composer test:integration (58 WordPress-backed tests, including new collision regressions) passing

…collisions

AVIF sibling filenames were derived from the source file's basename alone
(e.g. image-300x200.avif), duplicated across AvifVariantGenerator,
AvifCleanup, and FocalPointCropper. WordPress allows two distinct
attachments to share a basename across different extensions (e.g.
siegel.png and siegel.jpg), so both attachments resolved to the identical
AVIF path. That collision caused two production symptoms:

- 0-byte/corrupted AVIF output, from concurrent requests for different
  attachments racing to write the same shared file.
- Deleting one attachment (e.g. siegel.jpg) unlinking the AVIF sibling
  that actually belonged to a different, still-live attachment
  (e.g. siegel.png), via the delete_attachment cleanup hook.

Fixes by centralizing sibling-path derivation in AvifSiblingPath::for(),
which scopes the filename by attachment ID (image-300x200.{id}.avif),
and wiring it into all three previous call sites.

BREAKING CHANGE: AVIF sibling files generated before this change use the
old basename-only naming and are never read again after upgrading (the
resolver only looks up the new attachment-ID-scoped name). They become
orphaned on disk, not corrupted or unsafe, just dead weight, and every
image will lazily regenerate its AVIF sibling under the new name on next
resolution (bounded by the existing per-request generation cap, with the
original <img> as fallback in the meantime). See the PR description for
a cleanup command to reclaim the disk space.
@marcoluzi
marcoluzi merged commit ac6eb40 into main Aug 24, 2026
5 checks passed
@marcoluzi
marcoluzi deleted the fix/avif-sibling-path-collision branch August 24, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant