Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/cli/lib/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +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.
archiveBuilder.file( fs.realpathSync( path.join( wpContentPath, relativePath ) ), {
name: archiveEntryPath,
} );
try {
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 }` );
}
}

const wpConfigPath = path.join( siteFolder, 'wp-config.php' );
Expand Down
10 changes: 7 additions & 3 deletions apps/cli/lib/import-export/export/exporters/default-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,13 @@ export class DefaultExporter extends ImportExportEventEmitter implements Exporte
) {
continue;
}
this.archiveBuilder.file( fs.realpathSync( fullEntryPathOnDisk ), {
name: entryPathRelativeToArchiveRoot,
} );
try {
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 }` );
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions apps/cli/lib/tests/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ]: '<?php',
} );
// A broken symlink whose target was never created — mirrors the WP Cloud
// `advanced-cache.php` drop-in that a reprint pull leaves dangling.
vol.symlinkSync(
path.join( mockWpContentPath, 'wordpress/drop-ins/advanced-cache.php' ),
path.join( mockWpContentPath, 'advanced-cache.php' )
);
mockGlobResults( [ 'advanced-cache.php', 'index.php' ] );
const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} );

// 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 ]: '<?php' } );

Expand Down
10 changes: 9 additions & 1 deletion apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +1818 to +1826

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition 👍

}

export function openCertificate( _event: IpcMainInvokeEvent ) {
Expand Down
42 changes: 41 additions & 1 deletion apps/studio/src/tests/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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': '<?php' } );

expect(
getFileSize( mockIpcMainInvokeEvent, 'test-site-id', [ 'wp-content', 'index.php' ] )
).toBe( 5 );
} );

it( 'returns 0 for a dangling symlink instead of throwing', () => {
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();
} );
} );
4 changes: 3 additions & 1 deletion packages/common/lib/fs-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` );
}
} )
);
Expand Down
41 changes: 41 additions & 0 deletions packages/common/lib/tests/fs-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
Loading