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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions apps/cli/lib/hosts-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,31 @@ export const writeHostsFile = async ( content: string ): Promise< void > => {
};

/**
* Create a regular expression matching the hosts entry for a given domain:
* Create a regular expression matching the hosts entries for a given domain,
* covering both the IPv4 and IPv6 loopback addresses:
*
* 127.0.0.1 foo.wp.cloud # Port 8000
* ::1 foo.wp.cloud # Port 8000
*
* Remove backslashes as a security measure and escape regex special characters.
*/
export function createHostsEntryPattern( domain: string ): RegExp {
const sanitizedDomain = domain.replace( /\\/g, '' );
const escapedDomain = escapeRegex( sanitizedDomain );
return new RegExp( `127\\.0\\.0\\.1\\s+${ escapedDomain }(\\s|$)`, 'i' );
return new RegExp( `(?:127\\.0\\.0\\.1|::1)\\s+${ escapedDomain }(\\s|$)`, 'i' );
}

/**
* Build the canonical hosts entries for a domain. We add both an IPv4 and an
* IPv6 loopback entry: without the explicit `::1` line, macOS issues an IPv6
* (AAAA) lookup for `.local` domains that has to time out before the IPv4
* result is used, adding several seconds to every request.
*/
function createHostsEntries( encodedDomain: string, port: number ): string[] {
return [
`127.0.0.1 ${ encodedDomain } # Port ${ port }`,
`::1 ${ encodedDomain } # Port ${ port }`,
];
}

/**
Expand All @@ -86,13 +101,23 @@ export const addDomainToHosts = async ( domain: string, port: number ): Promise<

const newContent = updateStudioBlock( hostsContent, ( entries ) => {
const pattern = createHostsEntryPattern( encodedDomain );

// No changes if domain already present
if ( entries.some( ( entry ) => entry.match( pattern ) ) ) {
const desired = createHostsEntries( encodedDomain, port );
const existing = entries.filter( ( entry ) => entry.match( pattern ) );

// No changes if the canonical entries are already present. This also
// preserves ordering so unchanged sites don't trigger a write (and an
// elevated-permissions prompt) on every start.
if (
existing.length === desired.length &&
desired.every( ( entry, index ) => existing[ index ] === entry )
) {
return entries;
}

return [ ...entries, `127.0.0.1 ${ encodedDomain } # Port ${ port }` ];
// Otherwise drop any stale entries for this domain (e.g. a legacy
// IPv4-only line from before IPv6 support) and add the canonical pair.
const filtered = entries.filter( ( entry ) => ! entry.match( pattern ) );
return [ ...filtered, ...desired ];
} );

if ( newContent !== hostsContent ) {
Expand Down Expand Up @@ -157,7 +182,7 @@ export const updateDomainInHosts = async (
const oldPattern = createHostsEntryPattern( encodedOldDomain );
const newContent = updateStudioBlock( hostsContent, ( entries ) => {
const filtered = entries.filter( ( entry ) => ! entry.match( oldPattern ) );
return [ ...filtered, `127.0.0.1 ${ encodedNewDomain } # Port ${ port }` ];
return [ ...filtered, ...createHostsEntries( encodedNewDomain, port ) ];
} );

if ( newContent !== hostsContent ) {
Expand Down
81 changes: 78 additions & 3 deletions apps/cli/lib/tests/hosts-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { platform } from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { writeHostsFile } from 'cli/lib/hosts-file';
import { addDomainToHosts, removeDomainFromHosts, writeHostsFile } from 'cli/lib/hosts-file';
import { sudoExec } from 'cli/lib/sudo-exec';

const fsState = vi.hoisted( () => ( {
readContent: '',
writtenContent: null as string | null,
} ) );

vi.mock( 'os', async ( importOriginal ) => {
const actual = await importOriginal< typeof import('os') >();
const platformMock = vi.fn( () => 'linux' );
Expand All @@ -18,13 +23,18 @@ vi.mock( 'os', async ( importOriginal ) => {

vi.mock( 'fs', async ( importOriginal ) => {
const actual = await importOriginal< typeof import('fs') >();
const writeFile: typeof actual.writeFile = ( ( _p: unknown, _d: unknown, cb: unknown ) => {
const writeFile: typeof actual.writeFile = ( ( _p: unknown, data: unknown, cb: unknown ) => {
fsState.writtenContent = data as string;
( cb as ( err: null ) => void )( null );
} ) as typeof actual.writeFile;
const readFile: typeof actual.readFile = ( ( _p: unknown, _opts: unknown, cb: unknown ) => {
( cb as ( err: null, data: string ) => void )( null, fsState.readContent );
} ) as typeof actual.readFile;
return {
...actual,
default: { ...actual, writeFile },
default: { ...actual, writeFile, readFile },
writeFile,
readFile,
};
} );

Expand Down Expand Up @@ -78,3 +88,68 @@ describe( 'writeHostsFile', () => {
expect( options ).toMatchObject( { name: 'WordPress Studio' } );
} );
} );

describe( 'addDomainToHosts', () => {
beforeEach( () => {
vi.mocked( platform ).mockReturnValue( 'darwin' );
fsState.readContent = '';
fsState.writtenContent = null;
} );

it( 'writes both an IPv4 and an IPv6 loopback entry for a new domain', async () => {
await addDomainToHosts( 'my-project.local', 8000 );

expect( fsState.writtenContent ).toContain( '127.0.0.1 my-project.local # Port 8000' );
expect( fsState.writtenContent ).toContain( '::1 my-project.local # Port 8000' );
expect( fsState.writtenContent ).toContain( '# BEGIN WordPress Studio' );
expect( fsState.writtenContent ).toContain( '# END WordPress Studio' );
} );

it( 'migrates a legacy IPv4-only entry by adding the IPv6 entry', async () => {
fsState.readContent = [
'# BEGIN WordPress Studio',
'127.0.0.1 my-project.local # Port 8000',
'# END WordPress Studio',
].join( '\n' );

await addDomainToHosts( 'my-project.local', 8000 );

expect( fsState.writtenContent ).toContain( '127.0.0.1 my-project.local # Port 8000' );
expect( fsState.writtenContent ).toContain( '::1 my-project.local # Port 8000' );
} );

it( 'does not rewrite the hosts file when the entries are already present', async () => {
fsState.readContent = [
'# BEGIN WordPress Studio',
'127.0.0.1 my-project.local # Port 8000',
'::1 my-project.local # Port 8000',
'# END WordPress Studio',
].join( '\n' );

await addDomainToHosts( 'my-project.local', 8000 );

expect( fsState.writtenContent ).toBeNull();
expect( vi.mocked( sudoExec ) ).not.toHaveBeenCalled();
} );
} );

describe( 'removeDomainFromHosts', () => {
beforeEach( () => {
vi.mocked( platform ).mockReturnValue( 'darwin' );
fsState.readContent = [
'# BEGIN WordPress Studio',
'127.0.0.1 my-project.local # Port 8000',
'::1 my-project.local # Port 8000',
'# END WordPress Studio',
].join( '\n' );
fsState.writtenContent = null;
} );

it( 'removes both the IPv4 and IPv6 entries', async () => {
await removeDomainFromHosts( 'my-project.local' );

// The block is emptied and removed entirely.
expect( fsState.writtenContent ).not.toContain( 'my-project.local' );
expect( fsState.writtenContent ).not.toContain( '# BEGIN WordPress Studio' );
} );
} );