diff --git a/src/lib/symlink-manager.ts b/src/lib/symlink-manager.ts new file mode 100644 index 000000000..c37f2e404 --- /dev/null +++ b/src/lib/symlink-manager.ts @@ -0,0 +1,248 @@ +import fs, { FileChangeInfo } from 'node:fs/promises'; +import path from 'path'; +import { createNodeFsMountHandler } from '@php-wasm/node'; +import { PHP, UnmountFunction } from '@php-wasm/universal'; +import { pathExists } from './fs-utils'; + +type MountedTarget = { + preExisting: boolean; + referenceCount: number; + unmountFunction?: UnmountFunction | null; +}; + +/** + * Manages symlinks within a PHP WebAssembly runtime environment. In this setup, + * a site's directory from the host system is mounted inside the WebAssembly runtime. + * However, symlink targets are not automatically mounted, leaving symlinks pointing to nothing. + * This class resolves that issue by mounting the actual targets of symlinks. + * + * Key functionalities: + * 1. Scans for symlinks in the mounted directory + * 2. Mounts symlink targets in the WebAssembly runtime + * 3. Manages reference counting for mounted targets + * 4. Watches for file system changes and updates mounts accordingly + * + * The class ensures that symlinks within the PHP WebAssembly environment correctly + * reference their targets, maintaining the integrity of the file system structure + * across the host and WebAssembly boundary. + */ +export class SymlinkManager { + private symlinks: Map< string, string >; // Store relative path of symlink and its vfs realpath + private mountedTargets: Map< string, MountedTarget >; // Store the target of the symlink and its reference count + private php: PHP; + readonly projectPath: string; + private watcher?: AsyncIterable< FileChangeInfo< string > > | null; + private watcherAbortController?: AbortController | null; + + /** + * Create a new symlink manager. + * @param php + * @param projectPath + * + */ + constructor( php: PHP, projectPath: string ) { + this.symlinks = new Map< string, string >(); + this.mountedTargets = new Map< string, MountedTarget >(); + this.php = php; + this.projectPath = projectPath; + } + + /** + * Scan the project path for symlinks and mount their targets in the PHP runtime. + */ + async scanAndCreateSymlinks() { + for await ( const symlink of this.collectSymlinks( this.projectPath ) ) { + const relativePath = path.relative( this.projectPath, symlink ); + await this.addSymlink( relativePath ); + } + } + + /** + * Watches the project path for changes and update symlinks accordingly. + */ + startWatching() { + this.watcherAbortController = new AbortController(); + const { signal } = this.watcherAbortController; + this.watcher = fs.watch( this.projectPath, { recursive: true, signal } ); + + return new Promise< void >( ( resolve, reject ) => { + ( async () => { + try { + if ( ! this.watcher ) { + return resolve(); + } + for await ( const change of this.watcher ) { + await this.handleFileChange( change ); + } + resolve(); + } catch ( err ) { + // AbortError is expected when the watcher is stopped intentionally + if ( err instanceof Error && err.name === 'AbortError' ) { + return resolve(); + } + reject( err ); + } + } )(); + } ); + } + + /** + * Stop watching the project path for changes. + */ + async stopWatching() { + if ( ! this.watcherAbortController ) { + return; + } + this.watcherAbortController.abort( 'Watcher stopped' ); + this.watcher = null; + this.watcherAbortController = null; + } + + /** + * Handle a file change event. + * @param change + */ + private async handleFileChange( change: FileChangeInfo< string > ) { + const { filename } = change; + + if ( filename === null ) { + return; + } + + // Check if the file exists + const fullPath = path.join( this.projectPath, filename ); + + const fileExistsOnHost = await pathExists( fullPath ); + + // If the file does not exist, it was deleted. Check if it was a symlink and remove it. + if ( ! fileExistsOnHost && this.symlinks.has( filename ) ) { + await this.deleteMountedTarget( filename ); + } + + if ( ! fileExistsOnHost ) { + return; + } + + const stat = await fs.lstat( fullPath ); + if ( ! stat.isSymbolicLink() ) { + return; + } + + await this.addSymlink( filename ); + } + + /** + * Add a symlink to the PHP runtime by mounting the target path. + * @param filename + */ + private async addSymlink( filename: string ) { + const fullPath = path.join( this.projectPath, filename ); + const target = await fs.realpath( fullPath ); + + if ( ! this.php.requestHandler ) { + throw new Error( 'Request handler is not set' ); + } + + const vfsPath = path.posix.join( this.php.requestHandler.documentRoot, filename ); + + // Double check to ensure the symlink exists + if ( ! this.php.fileExists( vfsPath ) ) { + return; + } + + // Get the realpath of the symlink within the PHP runtime. + const vfsTarget = this.php.readlink( vfsPath ); + this.symlinks.set( filename, vfsTarget ); + + await this.mountTarget( vfsTarget, target ); + } + + /** + * Mount a target path in the PHP runtime. + * @param vfsTarget + * @param hostTarget + * @private + */ + private async mountTarget( vfsTarget: string, hostTarget: string ) { + let mountInfo = this.mountedTargets.get( vfsTarget ); + + if ( typeof mountInfo === 'undefined' ) { + mountInfo = { + preExisting: this.php.fileExists( vfsTarget ), + referenceCount: 0, + unmountFunction: null, + }; + this.mountedTargets.set( vfsTarget, mountInfo ); + } + + mountInfo.referenceCount++; + + if ( mountInfo.preExisting || mountInfo.unmountFunction !== null ) { + return; + } + + this.php.mkdir( vfsTarget ); + mountInfo.unmountFunction = await this.php.mount( + vfsTarget, + createNodeFsMountHandler( hostTarget ) + ); + } + + /** + * Delete a symlink from the PHP runtime. + * + * @param filename + */ + private async deleteMountedTarget( filename: string ) { + const vfsTarget = this.symlinks.get( filename ); + + if ( vfsTarget === undefined ) { + return; + } + + // Get the mount info for the target + const mountInfo = this.mountedTargets.get( vfsTarget ); + if ( typeof mountInfo === 'undefined' ) { + return; + } + + mountInfo.referenceCount--; + + // We can delete the symlink now as it is no longer needed. + this.symlinks.delete( filename ); + + // If the reference count is 0 and the unmount function is set, we can unmount the target + if ( mountInfo.referenceCount === 0 && mountInfo.unmountFunction && ! mountInfo.preExisting ) { + await mountInfo.unmountFunction(); + + // After unmounting, clear the mount target + if ( this.php.isFile( vfsTarget ) ) { + this.php.unlink( vfsTarget ); + } + + if ( this.php.isDir( vfsTarget ) ) { + this.php.rmdir( vfsTarget ); + } + + this.mountedTargets.delete( vfsTarget ); + } + } + + /** + * Collect all symlinks in a directory. + * @param dir + * @private + */ + private async *collectSymlinks( dir: string ): AsyncGenerator< string > { + const files = await fs.readdir( dir ); + for ( const file of files ) { + const filePath = path.join( dir, file ); + const stats = await fs.lstat( filePath ); + if ( stats.isSymbolicLink() ) { + yield filePath; + } else if ( stats.isDirectory() ) { + yield* this.collectSymlinks( filePath ); + } + } + } +} diff --git a/src/lib/tests/symlink-manage.test.ts b/src/lib/tests/symlink-manage.test.ts new file mode 100644 index 000000000..76e50de1d --- /dev/null +++ b/src/lib/tests/symlink-manage.test.ts @@ -0,0 +1,262 @@ +import { Stats } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'path'; +import { PHP, UnmountFunction } from '@php-wasm/universal'; +import { waitFor } from '@testing-library/react'; +import { pathExists } from '../fs-utils'; +import { SymlinkManager } from '../symlink-manager'; + +jest.mock( '@php-wasm/universal' ); +jest.mock( 'node:fs/promises' ); + +jest.mock( '../fs-utils' ); + +// Define a type for the private properties we need to access +type SymlinkManagerPrivateProperties = { + symlinks: Map< string, string >; + mountedTargets: Map< + string, + { referenceCount: number; unmountFunction: UnmountFunction | null } + >; + watcher: AsyncIterable< fs.FileChangeInfo< string > > | null; + watcherAbortController: AbortController | null; +}; + +describe( 'SymlinkManager', () => { + let symlinkManager: SymlinkManager; + let mockPHP: jest.Mocked< PHP >; + const mockProjectPath = '/mock/project/path'; + + beforeEach( () => { + mockPHP = { + fileExists: jest.fn().mockReturnValue( false ), + readlink: jest.fn(), + mkdir: jest.fn(), + mount: jest.fn(), + isFile: jest.fn(), + isDir: jest.fn(), + unlink: jest.fn(), + rmdir: jest.fn(), + requestHandler: { + documentRoot: '/mock/document/root', + }, + } as unknown as jest.Mocked< PHP >; + + symlinkManager = new SymlinkManager( mockPHP, mockProjectPath ); + + jest.mock( 'path', () => ( { + ...jest.requireActual( 'path' ), + basename: jest.fn( ( p: string ) => { + const parts = p.split( /[\\/]/ ); + return parts[ parts.length - 1 ]; + } ), + } ) ); + + // Mock pathExists function + ( pathExists as jest.Mock ).mockImplementation( ( fullPath: string ) => { + return Promise.resolve( fullPath.includes( 'existing' ) ); + } ); + } ); + + afterEach( () => { + jest.resetAllMocks(); + } ); + + describe( 'scanAndCreateSymlinks', () => { + it( 'should scan for symlinks and create them in the PHP runtime', async () => { + const mockSymlinks = [ + '/mock/project/path/existing/symlink1', + '/mock/project/path/existing/symlink2', + ]; + + mockPHP.fileExists.mockImplementation( ( path ) => ! path.includes( 'vfspath' ) ); + mockPHP.readlink.mockImplementation( ( path ) => '/mock/document/root/vfspath/' + path ); + + // Mock fs.readdir to return our mock symlinks + ( fs.readdir as jest.Mock ).mockResolvedValue( + mockSymlinks.map( ( s ) => path.basename( s ) ) + ); + + // Mock fs.lstat to indicate these are symlinks + ( fs.lstat as jest.Mock ).mockResolvedValue( { isSymbolicLink: () => true } as Stats ); + + // Mock fs.realpath to return a target for each symlink + ( fs.realpath as jest.Mock ).mockImplementation( ( p: string ) => + Promise.resolve( `/real/path/to/${ path.basename( p ) }` ) + ); + + await symlinkManager.scanAndCreateSymlinks(); + + // Verify that PHP mkdir and mount were called for each symlink + expect( mockPHP.mkdir ).toHaveBeenCalledTimes( 2 ); + expect( mockPHP.mount ).toHaveBeenCalledTimes( 2 ); + + // Verify that the symlinks were added to the internal map + expect( ( symlinkManager as unknown as SymlinkManagerPrivateProperties ).symlinks.size ).toBe( + 2 + ); + + // Verify that the mount targets were added to the internal map + expect( + ( symlinkManager as unknown as SymlinkManagerPrivateProperties ).mountedTargets.size + ).toBe( 2 ); + } ); + + it( 'should scan for symlinks and not mount duplicate links', async () => { + const mockSymlinks = [ + '/mock/project/path/existing/symlink1', + '/mock/project/path/existing/symlink2', + ]; + + mockPHP.fileExists.mockImplementation( ( path ) => ! path.includes( 'vfspath' ) ); + mockPHP.readlink.mockImplementation( () => '/mock/document/root/vfspath' ); + + // Mock fs.readdir to return our mock symlinks + ( fs.readdir as jest.Mock ).mockResolvedValue( + mockSymlinks.map( ( s ) => path.basename( s ) ) + ); + + // Mock fs.lstat to indicate these are symlinks + ( fs.lstat as jest.Mock ).mockResolvedValue( { isSymbolicLink: () => true } as Stats ); + + // Mock fs.realpath to return a target for each symlink + ( fs.realpath as jest.Mock ).mockImplementation( ( p: string ) => + Promise.resolve( `/real/path/to/${ path.basename( p ) }` ) + ); + + await symlinkManager.scanAndCreateSymlinks(); + + // Verify that PHP mkdir and mount were called for each symlink + expect( mockPHP.mkdir ).toHaveBeenCalledTimes( 1 ); + expect( mockPHP.mount ).toHaveBeenCalledTimes( 1 ); + + // Verify that the symlinks were added to the internal map + expect( ( symlinkManager as unknown as SymlinkManagerPrivateProperties ).symlinks.size ).toBe( + 2 + ); + + // Verify that the mount targets were added to the internal map + expect( + ( symlinkManager as unknown as SymlinkManagerPrivateProperties ).mountedTargets.size + ).toBe( 1 ); + } ); + } ); + + describe( 'startWatching and stopWatching', () => { + it( 'should start and stop watching for file changes', async () => { + const mockWatcher = { + [ Symbol.asyncIterator ]: jest.fn().mockImplementation( () => ( { + next: jest.fn().mockResolvedValue( { done: true, value: undefined } ), + } ) ), + }; + jest.spyOn( fs, 'watch' ).mockReturnValue( mockWatcher ); + + await symlinkManager.startWatching(); + expect( fs.watch ).toHaveBeenCalledWith( mockProjectPath, expect.any( Object ) ); + await symlinkManager.stopWatching(); + + expect( ( symlinkManager as unknown as SymlinkManagerPrivateProperties ).watcher ).toBeNull(); + expect( + ( symlinkManager as unknown as SymlinkManagerPrivateProperties ).watcherAbortController + ).toBeNull(); + } ); + } ); + + describe( 'file change handling', () => { + it( 'should handle symlink creation', async () => { + mockPHP.fileExists.mockImplementation( ( path ) => ! path.includes( 'vfspath' ) ); + mockPHP.readlink.mockImplementation( ( path ) => '/mock/document/root/vfspath/' + path ); + ( pathExists as jest.Mock ).mockResolvedValue( true ); + const mockWatcher = { + [ Symbol.asyncIterator ]: jest.fn().mockImplementation( () => ( { + next: jest + .fn() + .mockResolvedValueOnce( { + done: false, + value: { eventType: 'rename', filename: 'newSymlink' }, + } ) + .mockResolvedValueOnce( { done: true, value: undefined } ), + } ) ), + }; + jest.spyOn( fs, 'watch' ).mockReturnValue( mockWatcher ); + + ( fs.lstat as jest.Mock ).mockResolvedValue( { isSymbolicLink: () => true } as Stats ); + ( fs.realpath as jest.Mock ).mockResolvedValue( '/real/path/to/newSymlink' ); + + await symlinkManager.startWatching(); + await waitFor( () => { + expect( mockPHP.mkdir ).toHaveBeenCalled(); + expect( mockPHP.mount ).toHaveBeenCalled(); + const privateProps = symlinkManager as unknown as SymlinkManagerPrivateProperties; + expect( privateProps.symlinks.has( 'newSymlink' ) ).toBe( true ); + } ); + await symlinkManager.stopWatching(); + } ); + + it( 'should handle symlink deletion', async () => { + const privateProps = symlinkManager as unknown as SymlinkManagerPrivateProperties; + privateProps.symlinks.set( 'existingSymlink', '/vfs/path/to/target' ); + const unmountFunction = jest.fn(); + privateProps.mountedTargets.set( '/vfs/path/to/target', { + referenceCount: 1, + unmountFunction, + } ); + + const mockWatcher = { + [ Symbol.asyncIterator ]: jest.fn().mockImplementation( () => ( { + next: jest + .fn() + .mockResolvedValueOnce( { + done: false, + value: { eventType: 'rename', filename: 'existingSymlink' }, + } ) + .mockResolvedValueOnce( { done: true, value: undefined } ), + } ) ), + }; + jest.spyOn( fs, 'watch' ).mockReturnValue( mockWatcher ); + + ( pathExists as jest.Mock ).mockResolvedValue( false ); + + await symlinkManager.startWatching(); + await waitFor( () => { + expect( privateProps.symlinks.has( 'existingSymlink' ) ).toBe( false ); + expect( unmountFunction ).toHaveBeenCalled(); + } ); + await symlinkManager.stopWatching(); + } ); + + it( 'should handle symlink target deletion when no more references exist', async () => { + const privateProps = symlinkManager as unknown as SymlinkManagerPrivateProperties; + privateProps.symlinks.set( 'existingSymlink1', '/vfs/path/to/target' ); + privateProps.symlinks.set( 'existingSymlink2', '/vfs/path/to/target' ); + const unmountFunction = jest.fn(); + privateProps.mountedTargets.set( '/vfs/path/to/target', { + referenceCount: 2, + unmountFunction, + } ); + + const mockWatcher = { + [ Symbol.asyncIterator ]: jest.fn().mockImplementation( () => ( { + next: jest + .fn() + .mockResolvedValueOnce( { + done: false, + value: { eventType: 'rename', filename: 'existingSymlink1' }, + } ) + .mockResolvedValueOnce( { done: true, value: undefined } ), + } ) ), + }; + jest.spyOn( fs, 'watch' ).mockReturnValue( mockWatcher ); + + ( pathExists as jest.Mock ).mockResolvedValue( false ); + + await symlinkManager.startWatching(); + await waitFor( () => { + expect( privateProps.symlinks.has( 'existingSymlink1' ) ).toBe( false ); + expect( privateProps.symlinks.has( 'existingSymlink2' ) ).toBe( true ); + expect( unmountFunction ).toHaveBeenCalledTimes( 0 ); + } ); + await symlinkManager.stopWatching(); + } ); + } ); +} ); diff --git a/vendor/wp-now/src/start-server.ts b/vendor/wp-now/src/start-server.ts index d0aebfaee..933d321bd 100644 --- a/vendor/wp-now/src/start-server.ts +++ b/vendor/wp-now/src/start-server.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import { WPNowOptions } from './config'; -import { HTTPMethod, PHPRequestHandler } from '@php-wasm/universal'; +import { HTTPMethod } from '@php-wasm/universal'; import express from 'express'; import compression from 'compression'; import compressible from 'compressible'; @@ -82,6 +82,7 @@ export async function startServer( options: WPNowOptions = {} ): Promise< WPNowS new Promise( ( res ) => { server.close( () => { output?.log( `Server stopped` ); + php.exit(); res(); } ); } ), diff --git a/vendor/wp-now/src/wp-now.ts b/vendor/wp-now/src/wp-now.ts index 6c7b39eec..53d692fc6 100644 --- a/vendor/wp-now/src/wp-now.ts +++ b/vendor/wp-now/src/wp-now.ts @@ -36,6 +36,7 @@ import { output } from './output'; import getWpNowPath from './get-wp-now-path'; import getWordpressVersionsPath from './get-wordpress-versions-path'; import getSqlitePath from './get-sqlite-path'; +import { SymlinkManager } from '../../../src/lib/symlink-manager'; export default async function startWPNow( options: Partial< WPNowOptions > = {} @@ -134,6 +135,7 @@ async function getPHPInstance( ): Promise< { php: PHP; runtimeId: number } > { const id = await loadNodeRuntime( options.phpVersion ); const php = new PHP( id ); + php.requestHandler = requestHandler; await setPhpIniEntries( php, { memory_limit: '256M', @@ -173,6 +175,39 @@ async function prepareWordPress( php: PHP, options: WPNowOptions ) { await runWpPlaygroundMode( php, options ); break; } + + // Symlink manager is not yet supported on windows + // See: https://github.com/Automattic/studio/issues/548 + if ( process.platform !== 'win32' ) { + await startSymlinkManager(php, options.projectPath); + } +} + +/** + * Start the symlink manager + * + * The symlink manager ensures that we mount the targets of symlinks so that they + * work inside the php runtime. It also watches for changes to ensure symlinks + * are managed correctly. + * + * @param php + * @param projectPath + */ +async function startSymlinkManager(php: PHP, projectPath: string) { + const symlinkManager = new SymlinkManager(php, projectPath); + await symlinkManager.scanAndCreateSymlinks(); + symlinkManager.startWatching() + .catch((err) => { + output?.error('Error while watching for file changes', err); + }) + .finally(() => { + output?.log('Stopped watching for file changes'); + }); + + // Ensure that we stop watching for file changes when the runtime is exiting + php.addEventListener('runtime.beforedestroy', () => { + symlinkManager.stopWatching(); + }); } async function runIndexMode( php: PHP, { documentRoot, projectPath }: WPNowOptions ) {