From 807ea78f41d81a5a1e5dceebee2a7e643b667853 Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Wed, 1 Jul 2026 19:13:36 +0200 Subject: [PATCH 1/4] Skip dangling symlinks when archiving site content Reprint-pulled WP Cloud sites keep the advanced-cache.php drop-in as a symlink whose target (wordpress/drop-ins/...) isn't pulled, so it dangles. fs.realpathSync on it threw ENOENT and aborted the whole archive, failing 'preview create'/'update' (and push export) with 'Failed to create preview site: ENOENT ... advanced-cache.php'. Skip entries whose realpath fails, in both archiveSiteContent and the default exporter. --- apps/cli/lib/archive.ts | 12 ++++++++- .../export/exporters/default-exporter.ts | 12 ++++++++- apps/cli/lib/tests/archive.test.ts | 26 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/archive.ts b/apps/cli/lib/archive.ts index 0831af3041..8f41f0ea52 100644 --- a/apps/cli/lib/archive.ts +++ b/apps/cli/lib/archive.ts @@ -57,7 +57,17 @@ export async function archiveSiteContent( // realpath first, we ensure the source file data is always appended. This // is preferable to passing readable streams to `Archiver.append()`, which // can lead to EMFILE errors. - archiveBuilder.file( fs.realpathSync( path.join( wpContentPath, relativePath ) ), { + let resolvedPath: string; + try { + resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); + } catch ( error ) { + // Dangling symlink — e.g. a reprint-pulled site keeps the WP Cloud + // `advanced-cache.php` drop-in as a symlink whose target isn't pulled. + // Skip it rather than aborting the whole archive. + console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); + continue; + } + archiveBuilder.file( resolvedPath, { name: archiveEntryPath, } ); } diff --git a/apps/cli/lib/import-export/export/exporters/default-exporter.ts b/apps/cli/lib/import-export/export/exporters/default-exporter.ts index 0fc8035033..ec460eb4c1 100644 --- a/apps/cli/lib/import-export/export/exporters/default-exporter.ts +++ b/apps/cli/lib/import-export/export/exporters/default-exporter.ts @@ -271,7 +271,17 @@ export class DefaultExporter extends ImportExportEventEmitter implements Exporte ) { continue; } - this.archiveBuilder.file( fs.realpathSync( fullEntryPathOnDisk ), { + let resolvedPath: string; + try { + resolvedPath = fs.realpathSync( fullEntryPathOnDisk ); + } catch ( error ) { + // Dangling symlink — e.g. a reprint-pulled site keeps the WP Cloud + // `advanced-cache.php` drop-in as a symlink whose target isn't pulled. + // Skip it rather than aborting the whole archive. + console.warn( `Skipping ${ entryPathRelativeToArchiveRoot }: ${ error }` ); + continue; + } + this.archiveBuilder.file( resolvedPath, { name: entryPathRelativeToArchiveRoot, } ); } diff --git a/apps/cli/lib/tests/archive.test.ts b/apps/cli/lib/tests/archive.test.ts index ffae7e7c39..87f2a8c048 100644 --- a/apps/cli/lib/tests/archive.test.ts +++ b/apps/cli/lib/tests/archive.test.ts @@ -137,6 +137,32 @@ describe( 'Archive Module', () => { ); } ); + it( 'should skip a dangling symlink instead of aborting the archive', async () => { + vol.fromJSON( { + [ path.join( mockWpContentPath, 'index.php' ) ]: ' {} ); + + // Resolves (does not reject) and archives the good file, skipping the broken link. + await expect( archiveSiteContent( mockSiteFolder, mockArchivePath ) ).resolves.toBe( + mockArchiver + ); + + expect( archivedNames() ).toContain( 'wp-content/index.php' ); + expect( archivedNames() ).not.toContain( 'wp-content/advanced-cache.php' ); + expect( warnSpy ).toHaveBeenCalledWith( + expect.stringContaining( 'wp-content/advanced-cache.php' ) + ); + warnSpy.mockRestore(); + } ); + it( 'should include wp-config.php when it exists', async () => { vol.fromJSON( { [ mockWpConfigPath ]: ' Date: Wed, 1 Jul 2026 19:25:09 +0200 Subject: [PATCH 2/4] Rephrase the comment to be more succint --- apps/cli/lib/archive.ts | 4 +--- .../lib/import-export/export/exporters/default-exporter.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/cli/lib/archive.ts b/apps/cli/lib/archive.ts index 8f41f0ea52..304fba3bca 100644 --- a/apps/cli/lib/archive.ts +++ b/apps/cli/lib/archive.ts @@ -61,9 +61,7 @@ export async function archiveSiteContent( try { resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); } catch ( error ) { - // Dangling symlink — e.g. a reprint-pulled site keeps the WP Cloud - // `advanced-cache.php` drop-in as a symlink whose target isn't pulled. - // Skip it rather than aborting the whole archive. + // Dangling symlink. Skip it rather than aborting the whole archive. console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); continue; } diff --git a/apps/cli/lib/import-export/export/exporters/default-exporter.ts b/apps/cli/lib/import-export/export/exporters/default-exporter.ts index ec460eb4c1..c9e23da8b9 100644 --- a/apps/cli/lib/import-export/export/exporters/default-exporter.ts +++ b/apps/cli/lib/import-export/export/exporters/default-exporter.ts @@ -275,9 +275,7 @@ export class DefaultExporter extends ImportExportEventEmitter implements Exporte try { resolvedPath = fs.realpathSync( fullEntryPathOnDisk ); } catch ( error ) { - // Dangling symlink — e.g. a reprint-pulled site keeps the WP Cloud - // `advanced-cache.php` drop-in as a symlink whose target isn't pulled. - // Skip it rather than aborting the whole archive. + // Dangling symlink. Skip it rather than aborting the whole archive. console.warn( `Skipping ${ entryPathRelativeToArchiveRoot }: ${ error }` ); continue; } From a3aa5044bb5c2b0aa612ce8dbf0cce39727c471d Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Thu, 2 Jul 2026 16:53:36 +0200 Subject: [PATCH 3/4] Keep archiveBuilder in the same scope than fs realPathSync --- apps/cli/lib/archive.ts | 8 ++------ .../import-export/export/exporters/default-exporter.ts | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/cli/lib/archive.ts b/apps/cli/lib/archive.ts index 304fba3bca..4721869453 100644 --- a/apps/cli/lib/archive.ts +++ b/apps/cli/lib/archive.ts @@ -57,17 +57,13 @@ export async function archiveSiteContent( // realpath first, we ensure the source file data is always appended. This // is preferable to passing readable streams to `Archiver.append()`, which // can lead to EMFILE errors. - let resolvedPath: string; try { - resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); + const resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); + archiveBuilder.file( resolvedPath, { name: archiveEntryPath } ); } catch ( error ) { // Dangling symlink. Skip it rather than aborting the whole archive. console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); - continue; } - archiveBuilder.file( resolvedPath, { - name: archiveEntryPath, - } ); } const wpConfigPath = path.join( siteFolder, 'wp-config.php' ); diff --git a/apps/cli/lib/import-export/export/exporters/default-exporter.ts b/apps/cli/lib/import-export/export/exporters/default-exporter.ts index c9e23da8b9..e8cb8cf5d4 100644 --- a/apps/cli/lib/import-export/export/exporters/default-exporter.ts +++ b/apps/cli/lib/import-export/export/exporters/default-exporter.ts @@ -271,17 +271,13 @@ export class DefaultExporter extends ImportExportEventEmitter implements Exporte ) { continue; } - let resolvedPath: string; try { - resolvedPath = fs.realpathSync( fullEntryPathOnDisk ); + const resolvedPath = fs.realpathSync( fullEntryPathOnDisk ); + this.archiveBuilder.file( resolvedPath, { name: entryPathRelativeToArchiveRoot } ); } catch ( error ) { // Dangling symlink. Skip it rather than aborting the whole archive. console.warn( `Skipping ${ entryPathRelativeToArchiveRoot }: ${ error }` ); - continue; } - this.archiveBuilder.file( resolvedPath, { - name: entryPathRelativeToArchiveRoot, - } ); } } From 4ab9f7a3274f59805fcec7c61aa1ed2cbc66e5ed Mon Sep 17 00:00:00 2001 From: Roberto Aranda Date: Thu, 2 Jul 2026 17:12:33 +0200 Subject: [PATCH 4/4] Handle dangling symlinks in size calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push file-selection UI computes sizes via the getFileSize IPC handler and calculateDirectorySizeForArchive, both of which stat through symlinks. A dangling symlink (e.g. the advanced-cache.php drop-in on reprint-pulled sites) made getFileSize throw — logged twice with full stacks by Electron — and made the directory walk log a noisy stack trace. Since such entries are skipped when archiving, count them as zero and log a single Skipping line instead. --- apps/studio/src/ipc-handlers.ts | 10 +++++- apps/studio/src/tests/ipc-handlers.test.ts | 42 +++++++++++++++++++++- packages/common/lib/fs-utils.ts | 4 ++- packages/common/lib/tests/fs-utils.test.ts | 41 +++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 packages/common/lib/tests/fs-utils.test.ts diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index d1e679aad1..83933e536b 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -1815,7 +1815,15 @@ export function getFileSize( _event: IpcMainInvokeEvent, siteId: string, filePat if ( ! site ) { throw new Error( 'Site not found.' ); } - return fs.statSync( nodePath.join( site.details.path, ...filePath ) ).size; + const fullPath = nodePath.join( site.details.path, ...filePath ); + try { + return fs.statSync( fullPath ).size; + } catch ( error ) { + // Dangling symlink or unreadable entry. It's skipped when archiving, + // so count it as zero rather than failing the size check. + console.warn( `Skipping ${ fullPath }: ${ error }` ); + return 0; + } } export function openCertificate( _event: IpcMainInvokeEvent ) { diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts index 43b3e2cb3b..44cc9a6127 100644 --- a/apps/studio/src/tests/ipc-handlers.test.ts +++ b/apps/studio/src/tests/ipc-handlers.test.ts @@ -6,7 +6,13 @@ import { normalize } from 'path'; import { readFile } from 'atomically'; import { vol } from 'memfs'; import { vi } from 'vitest'; -import { createSite, isFullscreen, getXdebugEnabledSite, loadThemeDetails } from 'src/ipc-handlers'; +import { + createSite, + getFileSize, + getXdebugEnabledSite, + isFullscreen, + loadThemeDetails, +} from 'src/ipc-handlers'; import { captureSiteThumbnail } from 'src/lib/capture-site-thumbnail'; import { getMainWindow } from 'src/main-window'; import { SiteServer } from 'src/site-server'; @@ -292,3 +298,37 @@ describe( 'loadThemeDetails', () => { expect( captureSiteThumbnail ).toHaveBeenCalledWith( 'test-site-id', true ); } ); } ); + +describe( 'getFileSize', () => { + it( 'returns the file size', () => { + vi.mocked( SiteServer.get ).mockReturnValue( { + details: { path: '/test' }, + } as unknown as SiteServer ); + vol.fromJSON( { '/test/wp-content/index.php': ' { + vi.mocked( SiteServer.get ).mockReturnValue( { + details: { path: '/test' }, + } as unknown as SiteServer ); + // A broken symlink whose target was never created — mirrors the WP Cloud + // `advanced-cache.php` drop-in that a reprint pull leaves dangling. + vol.mkdirSync( '/test/wp-content', { recursive: true } ); + vol.symlinkSync( + '/test/wordpress/drop-ins/advanced-cache.php', + '/test/wp-content/advanced-cache.php' + ); + const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + + expect( + getFileSize( mockIpcMainInvokeEvent, 'test-site-id', [ 'wp-content', 'advanced-cache.php' ] ) + ).toBe( 0 ); + expect( warnSpy ).toHaveBeenCalledWith( expect.stringContaining( 'advanced-cache.php' ) ); + + warnSpy.mockRestore(); + } ); +} ); diff --git a/packages/common/lib/fs-utils.ts b/packages/common/lib/fs-utils.ts index 5d6b0de1fa..d7a13a740e 100644 --- a/packages/common/lib/fs-utils.ts +++ b/packages/common/lib/fs-utils.ts @@ -47,7 +47,9 @@ export function calculateDirectorySizeForArchive( totalSize += stats.size; } } catch ( error ) { - console.warn( `Error processing ${ filePath }:`, error ); + // Dangling symlink or unreadable entry. Skip it (uncounted) + // rather than failing the size calculation. + console.warn( `Skipping ${ filePath }: ${ error }` ); } } ) ); diff --git a/packages/common/lib/tests/fs-utils.test.ts b/packages/common/lib/tests/fs-utils.test.ts new file mode 100644 index 0000000000..d83543689c --- /dev/null +++ b/packages/common/lib/tests/fs-utils.test.ts @@ -0,0 +1,41 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { vi } from 'vitest'; +import { calculateDirectorySizeForArchive } from '@studio/common/lib/fs-utils'; + +describe( 'calculateDirectorySizeForArchive', () => { + let tempDir: string; + + beforeEach( () => { + tempDir = fs.mkdtempSync( path.join( os.tmpdir(), 'fs-utils-test-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tempDir, { recursive: true, force: true } ); + } ); + + it( 'sums the sizes of the directory contents', async () => { + fs.writeFileSync( path.join( tempDir, 'a.php' ), '12345' ); + fs.mkdirSync( path.join( tempDir, 'nested' ) ); + fs.writeFileSync( path.join( tempDir, 'nested', 'b.php' ), '123' ); + + await expect( calculateDirectorySizeForArchive( tempDir ) ).resolves.toBe( 8 ); + } ); + + it( 'skips a dangling symlink instead of failing the size calculation', async () => { + fs.writeFileSync( path.join( tempDir, 'a.php' ), '12345' ); + // A broken symlink whose target was never created — mirrors the WP Cloud + // `advanced-cache.php` drop-in that a reprint pull leaves dangling. + fs.symlinkSync( + path.join( tempDir, 'wordpress/drop-ins/advanced-cache.php' ), + path.join( tempDir, 'advanced-cache.php' ) + ); + const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + + await expect( calculateDirectorySizeForArchive( tempDir ) ).resolves.toBe( 5 ); + expect( warnSpy ).toHaveBeenCalledWith( expect.stringContaining( 'advanced-cache.php' ) ); + + warnSpy.mockRestore(); + } ); +} );