From 2ab3274a75939e0bbeb439be9c8e7ce948cbb41a Mon Sep 17 00:00:00 2001 From: Ivan Ottinger Date: Wed, 20 May 2026 14:12:21 +0200 Subject: [PATCH 01/14] Fix EEXIST when wp-content path is blocked by a non-directory file When a Pressable site is checked out via Git on Windows with core.symlinks=false, tracked symlinks (e.g. wp-content/plugins/akismet) materialize as small regular files containing the symlink target as text. The Jetpack importer's subsequent mkdir(...recursive:true) on that path throws EEXIST because the path exists and is not a directory, aborting the Sync pull. ensureDir() unlinks a non-directory blocking the target before retrying. Happy paths are unchanged. --- .../import/importers/importer.ts | 25 +++++++++- .../import/importers/tests/importer.test.ts | 46 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 apps/cli/lib/import-export/import/importers/tests/importer.test.ts diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 5a5f544f74..030ae1fd67 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -4,6 +4,7 @@ import { createInterface } from 'readline'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { generateBackupFilename } from '@studio/common/lib/generate-backup-filename'; import { ImportEvents } from '@studio/common/lib/import-export-events'; +import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { serializePlugins } from '@studio/common/lib/serialize-plugins'; import { SupportedPHPVersionsList } from '@studio/common/types/php-versions'; import { __, sprintf } from '@wordpress/i18n'; @@ -24,6 +25,28 @@ export interface Importer extends ImportExportEventEmitter { import( site: SiteData ): Promise< ImporterResult >; } +// mkdir with recursive:true throws EEXIST only when a path along the way exists +// and is not a directory. On Windows this surfaces after `git checkout` materializes +// a tracked symlink as a regular file (Pressable repos often track managed plugins +// like akismet/jetpack as symlinks, which become small text files when checked out +// without core.symlinks=true). Replace the offender and retry. +export async function ensureDir( dir: string ): Promise< void > { + try { + await fs.promises.mkdir( dir, { recursive: true } ); + } catch ( error ) { + if ( ! isErrnoException( error ) || error.code !== 'EEXIST' ) { + throw error; + } + const blockingPath = error.path ?? dir; + const stat = await fs.promises.lstat( blockingPath ); + if ( stat.isDirectory() ) { + throw error; + } + await fs.promises.unlink( blockingPath ); + await fs.promises.mkdir( dir, { recursive: true } ); + } +} + abstract class BaseImporter extends ImportExportEventEmitter implements Importer { protected meta?: MetaFileData; @@ -232,7 +255,7 @@ abstract class BaseBackupImporter extends BaseImporter { ); const destPath = path.join( wpContentDestDir, relativePath ); - await fs.promises.mkdir( path.dirname( destPath ), { recursive: true } ); + await ensureDir( path.dirname( destPath ) ); await fs.promises.copyFile( file, destPath ); processedItems++; diff --git a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts new file mode 100644 index 0000000000..5cea7847d5 --- /dev/null +++ b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ensureDir } from '../importer'; + +describe( 'ensureDir', () => { + let tmpDir: string; + + beforeEach( () => { + tmpDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-ensure-dir-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tmpDir, { recursive: true, force: true } ); + } ); + + it( 'creates a directory that does not exist', async () => { + const target = path.join( tmpDir, 'a', 'b', 'c' ); + await ensureDir( target ); + expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); + } ); + + it( 'is a no-op when the directory already exists', async () => { + const target = path.join( tmpDir, 'existing' ); + fs.mkdirSync( target ); + await expect( ensureDir( target ) ).resolves.toBeUndefined(); + expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); + } ); + + // Reproduces the EEXIST seen when a Pressable site has been checked out on + // Windows with core.symlinks=false: a tracked symlink at e.g. + // wp-content/plugins/akismet is materialized as a small regular file, and the + // next Studio pull's mkdir(...recursive:true) on that path throws EEXIST. + it( 'replaces a non-directory file blocking the target path', async () => { + const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); + fs.mkdirSync( plugins, { recursive: true } ); + const blocker = path.join( plugins, 'akismet' ); + fs.writeFileSync( blocker, '/managed/akismet' ); + expect( fs.lstatSync( blocker ).isFile() ).toBe( true ); + + await ensureDir( blocker ); + + expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); + } ); +} ); From 12431b648b63685580b6a5ea624ada3e6f9aae73 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger Date: Wed, 20 May 2026 14:37:02 +0200 Subject: [PATCH 02/14] Simplify ensureDir by dropping the defensive isDirectory check mkdir with recursive:true is documented to throw EEXIST only when the path exists and is not a directory, so the post-catch lstat was guarding an impossible case. --- apps/cli/lib/import-export/import/importers/importer.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 030ae1fd67..f33edf3a8e 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -37,12 +37,7 @@ export async function ensureDir( dir: string ): Promise< void > { if ( ! isErrnoException( error ) || error.code !== 'EEXIST' ) { throw error; } - const blockingPath = error.path ?? dir; - const stat = await fs.promises.lstat( blockingPath ); - if ( stat.isDirectory() ) { - throw error; - } - await fs.promises.unlink( blockingPath ); + await fs.promises.unlink( error.path ?? dir ); await fs.promises.mkdir( dir, { recursive: true } ); } } From 55ba472a83942d75b06a6cfc8599166addc3bc5b Mon Sep 17 00:00:00 2001 From: Ivan Ottinger Date: Wed, 20 May 2026 14:57:47 +0200 Subject: [PATCH 03/14] Handle ENOTDIR ancestor blocker and log the unlink mkdir(recursive:true) throws ENOTDIR (not EEXIST) when the blocker is an intermediate path component, e.g. when the importer copies a deep file like wp-content/plugins/akismet/_inc/akismet.css before a top-level file in the same plugin. Catch both codes, require error.path, and warn about the removed blocker so production logs reflect when the recovery fires. Adds a regression test for the ancestor-blocker case. --- .../import/importers/importer.ts | 20 ++++++++++++------- .../import/importers/tests/importer.test.ts | 18 +++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index f33edf3a8e..5bc92d671c 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -25,19 +25,25 @@ export interface Importer extends ImportExportEventEmitter { import( site: SiteData ): Promise< ImporterResult >; } -// mkdir with recursive:true throws EEXIST only when a path along the way exists -// and is not a directory. On Windows this surfaces after `git checkout` materializes -// a tracked symlink as a regular file (Pressable repos often track managed plugins -// like akismet/jetpack as symlinks, which become small text files when checked out -// without core.symlinks=true). Replace the offender and retry. +// mkdir with recursive:true throws EEXIST (final-component blocker) or ENOTDIR +// (intermediate-component blocker) when a non-directory exists along the path. +// On Windows this surfaces after `git checkout` materializes a tracked symlink +// as a regular file (Pressable repos often track managed plugins like akismet +// and jetpack as symlinks, which become small text files when checked out +// without core.symlinks=true). Unlink the blocker named by error.path and retry. export async function ensureDir( dir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); } catch ( error ) { - if ( ! isErrnoException( error ) || error.code !== 'EEXIST' ) { + if ( + ! isErrnoException( error ) || + ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) || + ! error.path + ) { throw error; } - await fs.promises.unlink( error.path ?? dir ); + console.warn( `ensureDir: removed non-directory blocker at ${ error.path }` ); + await fs.promises.unlink( error.path ); await fs.promises.mkdir( dir, { recursive: true } ); } } diff --git a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts index 5cea7847d5..4ad2a15267 100644 --- a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts +++ b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts @@ -43,4 +43,22 @@ describe( 'ensureDir', () => { expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); } ); + + // Same blocker shape, but the importer calls mkdir on a DEEPER path (e.g. when + // the first file copied is wp-content/plugins/akismet/_inc/akismet.css). Node + // throws ENOTDIR — not EEXIST — and error.path points at the ancestor blocker, + // not at the path we passed in. The helper must unlink the blocker reported by + // the error, not the path it was called with. + it( 'replaces a non-directory file blocking an ancestor of the target path', async () => { + const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); + fs.mkdirSync( plugins, { recursive: true } ); + const blocker = path.join( plugins, 'akismet' ); + fs.writeFileSync( blocker, '/managed/akismet' ); + + const deeper = path.join( blocker, '_inc' ); + await ensureDir( deeper ); + + expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); + expect( fs.lstatSync( deeper ).isDirectory() ).toBe( true ); + } ); } ); From 267810430f410049c57e08e2eb1264d3f107c3b2 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger Date: Wed, 20 May 2026 15:16:03 +0200 Subject: [PATCH 04/14] Walk up to find blocker; do not trust error.path cross-platform Node populates error.path differently on Linux (the original mkdir target) versus Windows (the actual non-directory blocker). The previous code unlinked error.path and worked only on Windows; on Linux the unlink hit the same ENOTDIR. Replace it with a directed lstat walk-up from dir to locate the real blocker, then unlink that. --- .../import/importers/importer.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 5bc92d671c..7d0df81911 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -30,24 +30,48 @@ export interface Importer extends ImportExportEventEmitter { // On Windows this surfaces after `git checkout` materializes a tracked symlink // as a regular file (Pressable repos often track managed plugins like akismet // and jetpack as symlinks, which become small text files when checked out -// without core.symlinks=true). Unlink the blocker named by error.path and retry. +// without core.symlinks=true). Locate the blocker by walking up from dir, unlink +// it, and retry — error.path is not reliable here because Node populates it with +// the original mkdir target on Linux but with the actual blocker on Windows. export async function ensureDir( dir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); } catch ( error ) { - if ( - ! isErrnoException( error ) || - ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) || - ! error.path - ) { + if ( ! isErrnoException( error ) || ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) ) { throw error; } - console.warn( `ensureDir: removed non-directory blocker at ${ error.path }` ); - await fs.promises.unlink( error.path ); + const blocker = await findNonDirectoryAncestor( dir ); + if ( ! blocker ) { + throw error; + } + console.warn( `ensureDir: removed non-directory blocker at ${ blocker }` ); + await fs.promises.unlink( blocker ); await fs.promises.mkdir( dir, { recursive: true } ); } } +async function findNonDirectoryAncestor( start: string ): Promise< string | null > { + let current = start; + while ( true ) { + try { + const stat = await fs.promises.lstat( current ); + return stat.isDirectory() ? null : current; + } catch ( error ) { + if ( + ! isErrnoException( error ) || + ( error.code !== 'ENOENT' && error.code !== 'ENOTDIR' ) + ) { + throw error; + } + const parent = path.dirname( current ); + if ( parent === current ) { + return null; + } + current = parent; + } + } +} + abstract class BaseImporter extends ImportExportEventEmitter implements Importer { protected meta?: MetaFileData; From 9296fa3bc6a34505a823292372f7856342bb96e1 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger Date: Wed, 20 May 2026 15:41:08 +0200 Subject: [PATCH 05/14] Trim ensureDir comments --- apps/cli/lib/import-export/import/importers/importer.ts | 9 +-------- .../import/importers/tests/importer.test.ts | 9 --------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 7d0df81911..0dc26ba212 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -25,14 +25,7 @@ export interface Importer extends ImportExportEventEmitter { import( site: SiteData ): Promise< ImporterResult >; } -// mkdir with recursive:true throws EEXIST (final-component blocker) or ENOTDIR -// (intermediate-component blocker) when a non-directory exists along the path. -// On Windows this surfaces after `git checkout` materializes a tracked symlink -// as a regular file (Pressable repos often track managed plugins like akismet -// and jetpack as symlinks, which become small text files when checked out -// without core.symlinks=true). Locate the blocker by walking up from dir, unlink -// it, and retry — error.path is not reliable here because Node populates it with -// the original mkdir target on Linux but with the actual blocker on Windows. +// Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path. export async function ensureDir( dir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); diff --git a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts index 4ad2a15267..816e8c0571 100644 --- a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts +++ b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts @@ -28,10 +28,6 @@ describe( 'ensureDir', () => { expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); } ); - // Reproduces the EEXIST seen when a Pressable site has been checked out on - // Windows with core.symlinks=false: a tracked symlink at e.g. - // wp-content/plugins/akismet is materialized as a small regular file, and the - // next Studio pull's mkdir(...recursive:true) on that path throws EEXIST. it( 'replaces a non-directory file blocking the target path', async () => { const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); fs.mkdirSync( plugins, { recursive: true } ); @@ -44,11 +40,6 @@ describe( 'ensureDir', () => { expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); } ); - // Same blocker shape, but the importer calls mkdir on a DEEPER path (e.g. when - // the first file copied is wp-content/plugins/akismet/_inc/akismet.css). Node - // throws ENOTDIR — not EEXIST — and error.path points at the ancestor blocker, - // not at the path we passed in. The helper must unlink the blocker reported by - // the error, not the path it was called with. it( 'replaces a non-directory file blocking an ancestor of the target path', async () => { const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); fs.mkdirSync( plugins, { recursive: true } ); From 6aff81956c30e6e2861e53a7975ef7ac47ac8c09 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 20 May 2026 16:41:59 +0200 Subject: [PATCH 06/14] Bound ensureDir blocker search to a rootDir Pass the destination wp-content directory to ensureDir and refuse to unlink any blocker that resolves outside of it. Without this guard, a malformed archive entry that escapes the destination tree (e.g. via `..` segments) could cause an unrelated file on disk to be unlinked before mkdir retries. --- .../import/importers/importer.ts | 20 ++++++++---- .../import/importers/tests/importer.test.ts | 31 +++++++++++++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 0dc26ba212..0acb6bcaae 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -26,14 +26,17 @@ export interface Importer extends ImportExportEventEmitter { } // Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path. -export async function ensureDir( dir: string ): Promise< void > { +// rootDir bounds the search: if the blocker resolves outside rootDir (e.g. an +// archive entry that escapes the destination tree via `..`), rethrow the +// original error rather than unlink an unrelated file. +export async function ensureDir( dir: string, rootDir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); } catch ( error ) { if ( ! isErrnoException( error ) || ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) ) { throw error; } - const blocker = await findNonDirectoryAncestor( dir ); + const blocker = await findNonDirectoryAncestor( dir, rootDir ); if ( ! blocker ) { throw error; } @@ -43,9 +46,13 @@ export async function ensureDir( dir: string ): Promise< void > { } } -async function findNonDirectoryAncestor( start: string ): Promise< string | null > { - let current = start; - while ( true ) { +async function findNonDirectoryAncestor( + start: string, + rootDir: string +): Promise< string | null > { + const resolvedRoot = path.resolve( rootDir ); + let current = path.resolve( start ); + while ( current === resolvedRoot || current.startsWith( resolvedRoot + path.sep ) ) { try { const stat = await fs.promises.lstat( current ); return stat.isDirectory() ? null : current; @@ -63,6 +70,7 @@ async function findNonDirectoryAncestor( start: string ): Promise< string | null current = parent; } } + return null; } abstract class BaseImporter extends ImportExportEventEmitter implements Importer { @@ -273,7 +281,7 @@ abstract class BaseBackupImporter extends BaseImporter { ); const destPath = path.join( wpContentDestDir, relativePath ); - await ensureDir( path.dirname( destPath ) ); + await ensureDir( path.dirname( destPath ), wpContentDestDir ); await fs.promises.copyFile( file, destPath ); processedItems++; diff --git a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts index 816e8c0571..510e468ccd 100644 --- a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts +++ b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts @@ -17,39 +17,58 @@ describe( 'ensureDir', () => { it( 'creates a directory that does not exist', async () => { const target = path.join( tmpDir, 'a', 'b', 'c' ); - await ensureDir( target ); + await ensureDir( target, tmpDir ); expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); } ); it( 'is a no-op when the directory already exists', async () => { const target = path.join( tmpDir, 'existing' ); fs.mkdirSync( target ); - await expect( ensureDir( target ) ).resolves.toBeUndefined(); + await expect( ensureDir( target, tmpDir ) ).resolves.toBeUndefined(); expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); } ); it( 'replaces a non-directory file blocking the target path', async () => { - const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); + const wpContent = path.join( tmpDir, 'wp-content' ); + const plugins = path.join( wpContent, 'plugins' ); fs.mkdirSync( plugins, { recursive: true } ); const blocker = path.join( plugins, 'akismet' ); fs.writeFileSync( blocker, '/managed/akismet' ); expect( fs.lstatSync( blocker ).isFile() ).toBe( true ); - await ensureDir( blocker ); + await ensureDir( blocker, wpContent ); expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); } ); it( 'replaces a non-directory file blocking an ancestor of the target path', async () => { - const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); + const wpContent = path.join( tmpDir, 'wp-content' ); + const plugins = path.join( wpContent, 'plugins' ); fs.mkdirSync( plugins, { recursive: true } ); const blocker = path.join( plugins, 'akismet' ); fs.writeFileSync( blocker, '/managed/akismet' ); const deeper = path.join( blocker, '_inc' ); - await ensureDir( deeper ); + await ensureDir( deeper, wpContent ); expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); expect( fs.lstatSync( deeper ).isDirectory() ).toBe( true ); } ); + + // Defense-in-depth: if a malformed archive entry resolves to a path outside + // the destination tree (e.g. via `..` segments) and that resolved path is + // blocked by a non-directory file, ensureDir must NOT unlink it. The + // original mkdir error should propagate and the on-disk file must remain. + it( 'refuses to unlink a blocker outside rootDir', async () => { + const wpContent = path.join( tmpDir, 'wp-content' ); + fs.mkdirSync( wpContent, { recursive: true } ); + const outsideBlocker = path.join( tmpDir, 'outside-file' ); + fs.writeFileSync( outsideBlocker, 'do-not-delete' ); + + await expect( ensureDir( outsideBlocker, wpContent ) ).rejects.toMatchObject( { + code: 'EEXIST', + } ); + expect( fs.lstatSync( outsideBlocker ).isFile() ).toBe( true ); + expect( fs.readFileSync( outsideBlocker, 'utf-8' ) ).toBe( 'do-not-delete' ); + } ); } ); From cc44b6026e4682d8169c226757c77baa1a5503ec Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 20 May 2026 16:45:59 +0200 Subject: [PATCH 07/14] Revert "Bound ensureDir blocker search to a rootDir" This reverts commit 6aff81956c30e6e2861e53a7975ef7ac47ac8c09. --- .../import/importers/importer.ts | 20 ++++-------- .../import/importers/tests/importer.test.ts | 31 ++++--------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 0acb6bcaae..0dc26ba212 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -26,17 +26,14 @@ export interface Importer extends ImportExportEventEmitter { } // Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path. -// rootDir bounds the search: if the blocker resolves outside rootDir (e.g. an -// archive entry that escapes the destination tree via `..`), rethrow the -// original error rather than unlink an unrelated file. -export async function ensureDir( dir: string, rootDir: string ): Promise< void > { +export async function ensureDir( dir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); } catch ( error ) { if ( ! isErrnoException( error ) || ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) ) { throw error; } - const blocker = await findNonDirectoryAncestor( dir, rootDir ); + const blocker = await findNonDirectoryAncestor( dir ); if ( ! blocker ) { throw error; } @@ -46,13 +43,9 @@ export async function ensureDir( dir: string, rootDir: string ): Promise< void > } } -async function findNonDirectoryAncestor( - start: string, - rootDir: string -): Promise< string | null > { - const resolvedRoot = path.resolve( rootDir ); - let current = path.resolve( start ); - while ( current === resolvedRoot || current.startsWith( resolvedRoot + path.sep ) ) { +async function findNonDirectoryAncestor( start: string ): Promise< string | null > { + let current = start; + while ( true ) { try { const stat = await fs.promises.lstat( current ); return stat.isDirectory() ? null : current; @@ -70,7 +63,6 @@ async function findNonDirectoryAncestor( current = parent; } } - return null; } abstract class BaseImporter extends ImportExportEventEmitter implements Importer { @@ -281,7 +273,7 @@ abstract class BaseBackupImporter extends BaseImporter { ); const destPath = path.join( wpContentDestDir, relativePath ); - await ensureDir( path.dirname( destPath ), wpContentDestDir ); + await ensureDir( path.dirname( destPath ) ); await fs.promises.copyFile( file, destPath ); processedItems++; diff --git a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts index 510e468ccd..816e8c0571 100644 --- a/apps/cli/lib/import-export/import/importers/tests/importer.test.ts +++ b/apps/cli/lib/import-export/import/importers/tests/importer.test.ts @@ -17,58 +17,39 @@ describe( 'ensureDir', () => { it( 'creates a directory that does not exist', async () => { const target = path.join( tmpDir, 'a', 'b', 'c' ); - await ensureDir( target, tmpDir ); + await ensureDir( target ); expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); } ); it( 'is a no-op when the directory already exists', async () => { const target = path.join( tmpDir, 'existing' ); fs.mkdirSync( target ); - await expect( ensureDir( target, tmpDir ) ).resolves.toBeUndefined(); + await expect( ensureDir( target ) ).resolves.toBeUndefined(); expect( fs.lstatSync( target ).isDirectory() ).toBe( true ); } ); it( 'replaces a non-directory file blocking the target path', async () => { - const wpContent = path.join( tmpDir, 'wp-content' ); - const plugins = path.join( wpContent, 'plugins' ); + const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); fs.mkdirSync( plugins, { recursive: true } ); const blocker = path.join( plugins, 'akismet' ); fs.writeFileSync( blocker, '/managed/akismet' ); expect( fs.lstatSync( blocker ).isFile() ).toBe( true ); - await ensureDir( blocker, wpContent ); + await ensureDir( blocker ); expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); } ); it( 'replaces a non-directory file blocking an ancestor of the target path', async () => { - const wpContent = path.join( tmpDir, 'wp-content' ); - const plugins = path.join( wpContent, 'plugins' ); + const plugins = path.join( tmpDir, 'wp-content', 'plugins' ); fs.mkdirSync( plugins, { recursive: true } ); const blocker = path.join( plugins, 'akismet' ); fs.writeFileSync( blocker, '/managed/akismet' ); const deeper = path.join( blocker, '_inc' ); - await ensureDir( deeper, wpContent ); + await ensureDir( deeper ); expect( fs.lstatSync( blocker ).isDirectory() ).toBe( true ); expect( fs.lstatSync( deeper ).isDirectory() ).toBe( true ); } ); - - // Defense-in-depth: if a malformed archive entry resolves to a path outside - // the destination tree (e.g. via `..` segments) and that resolved path is - // blocked by a non-directory file, ensureDir must NOT unlink it. The - // original mkdir error should propagate and the on-disk file must remain. - it( 'refuses to unlink a blocker outside rootDir', async () => { - const wpContent = path.join( tmpDir, 'wp-content' ); - fs.mkdirSync( wpContent, { recursive: true } ); - const outsideBlocker = path.join( tmpDir, 'outside-file' ); - fs.writeFileSync( outsideBlocker, 'do-not-delete' ); - - await expect( ensureDir( outsideBlocker, wpContent ) ).rejects.toMatchObject( { - code: 'EEXIST', - } ); - expect( fs.lstatSync( outsideBlocker ).isFile() ).toBe( true ); - expect( fs.readFileSync( outsideBlocker, 'utf-8' ) ).toBe( 'do-not-delete' ); - } ); } ); From d160c34f8906dd721f5c8d3d26e651165b3d4d4e Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 20 May 2026 17:25:10 +0200 Subject: [PATCH 08/14] Simplify findNonDirectoryAncestor with a top-down walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous bottom-up walk had to special-case ENOTDIR on lstat to keep climbing through a file blocker on macOS (where lstat below a file throws ENOTDIR rather than ENOENT). Walking top-down sidesteps the quirk — we never stat below a file because we stop at the first non-directory. Use stat (not lstat) so that symlinks to directories, which mkdir traverses without complaint, are recognized as directories rather than flagged as blockers. --- .../import/importers/importer.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 12f0f77bc2..4e63a6726f 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -47,26 +47,30 @@ export async function ensureDir( dir: string ): Promise< void > { } } +// Walk the path top-down: the first existing component that isn't a directory +// is the blocker. The first missing component means no blocker exists. Uses +// stat (not lstat) so that symlinks to directories — which mkdir traverses +// happily — are treated as directories rather than as blockers. async function findNonDirectoryAncestor( start: string ): Promise< string | null > { - let current = start; - while ( true ) { + const resolved = path.resolve( start ); + const root = path.parse( resolved ).root; + const parts = resolved.slice( root.length ).split( path.sep ).filter( Boolean ); + let cur = root; + for ( const part of parts ) { + cur = path.join( cur, part ); try { - const stat = await fs.promises.lstat( current ); - return stat.isDirectory() ? null : current; - } catch ( error ) { - if ( - ! isErrnoException( error ) || - ( error.code !== 'ENOENT' && error.code !== 'ENOTDIR' ) - ) { - throw error; + const stat = await fs.promises.stat( cur ); + if ( ! stat.isDirectory() ) { + return cur; } - const parent = path.dirname( current ); - if ( parent === current ) { + } catch ( error ) { + if ( isErrnoException( error ) && error.code === 'ENOENT' ) { return null; } - current = parent; + throw error; } } + return null; } abstract class BaseImporter extends ImportExportEventEmitter implements Importer { From 3b3381a5e3dd4184e5c9cf7e57c4ddbc1422073b Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 20 May 2026 17:28:57 +0200 Subject: [PATCH 09/14] Drop redundant ensureDir comment --- apps/cli/lib/import-export/import/importers/importer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 4e63a6726f..dcf1a395b8 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -29,7 +29,6 @@ export interface Importer extends ImportExportEventEmitter { import( site: SiteData ): Promise< ImporterResult >; } -// Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path. export async function ensureDir( dir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); From 191924b306f907df61c52e2e142b36c5eb23488f Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Wed, 20 May 2026 17:32:40 +0200 Subject: [PATCH 10/14] Restore ensureDir intent comment and trim the algorithm restatement --- apps/cli/lib/import-export/import/importers/importer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index dcf1a395b8..8b9afc3fe1 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -29,6 +29,7 @@ export interface Importer extends ImportExportEventEmitter { import( site: SiteData ): Promise< ImporterResult >; } +// Recovers from EEXIST/ENOTDIR by removing a non-directory blocker on the path. export async function ensureDir( dir: string ): Promise< void > { try { await fs.promises.mkdir( dir, { recursive: true } ); @@ -46,10 +47,8 @@ export async function ensureDir( dir: string ): Promise< void > { } } -// Walk the path top-down: the first existing component that isn't a directory -// is the blocker. The first missing component means no blocker exists. Uses // stat (not lstat) so that symlinks to directories — which mkdir traverses -// happily — are treated as directories rather than as blockers. +// without complaint — are treated as directories rather than as blockers. async function findNonDirectoryAncestor( start: string ): Promise< string | null > { const resolved = path.resolve( start ); const root = path.parse( resolved ).root; From bac7a8b723d6ccd8607952f5a6154789b4e799a4 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Thu, 21 May 2026 09:41:42 +0200 Subject: [PATCH 11/14] Log ensureDir blocker removal only after unlink succeeds --- apps/cli/lib/import-export/import/importers/importer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 8b9afc3fe1..84a4ee9188 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -41,8 +41,8 @@ export async function ensureDir( dir: string ): Promise< void > { if ( ! blocker ) { throw error; } - console.warn( `ensureDir: removed non-directory blocker at ${ blocker }` ); await fs.promises.unlink( blocker ); + console.warn( `ensureDir: removed non-directory blocker at ${ blocker }` ); await fs.promises.mkdir( dir, { recursive: true } ); } } From 27d51aaf57a5720e805277fa8cb16655e6e9c894 Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Thu, 21 May 2026 09:49:01 +0200 Subject: [PATCH 12/14] Drop findNonDirectoryAncestor doc comment --- apps/cli/lib/import-export/import/importers/importer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 84a4ee9188..5730f83e86 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -47,8 +47,6 @@ export async function ensureDir( dir: string ): Promise< void > { } } -// stat (not lstat) so that symlinks to directories — which mkdir traverses -// without complaint — are treated as directories rather than as blockers. async function findNonDirectoryAncestor( start: string ): Promise< string | null > { const resolved = path.resolve( start ); const root = path.parse( resolved ).root; From cef106d577595b87a650365550573531bfe139ac Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Thu, 21 May 2026 10:10:14 +0200 Subject: [PATCH 13/14] Recurse upward in ensureDir instead of a separate path walker --- .../import/importers/importer.ts | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 5730f83e86..8bc891de9e 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -37,36 +37,24 @@ export async function ensureDir( dir: string ): Promise< void > { if ( ! isErrnoException( error ) || ( error.code !== 'EEXIST' && error.code !== 'ENOTDIR' ) ) { throw error; } - const blocker = await findNonDirectoryAncestor( dir ); - if ( ! blocker ) { + const parent = path.dirname( dir ); + if ( parent === dir ) { throw error; } - await fs.promises.unlink( blocker ); - console.warn( `ensureDir: removed non-directory blocker at ${ blocker }` ); - await fs.promises.mkdir( dir, { recursive: true } ); - } -} - -async function findNonDirectoryAncestor( start: string ): Promise< string | null > { - const resolved = path.resolve( start ); - const root = path.parse( resolved ).root; - const parts = resolved.slice( root.length ).split( path.sep ).filter( Boolean ); - let cur = root; - for ( const part of parts ) { - cur = path.join( cur, part ); + await ensureDir( parent ); try { - const stat = await fs.promises.stat( cur ); + const stat = await fs.promises.stat( dir ); if ( ! stat.isDirectory() ) { - return cur; + await fs.promises.unlink( dir ); + console.warn( `ensureDir: removed non-directory blocker at ${ dir }` ); } - } catch ( error ) { - if ( isErrnoException( error ) && error.code === 'ENOENT' ) { - return null; + } catch ( e ) { + if ( ! isErrnoException( e ) || e.code !== 'ENOENT' ) { + throw e; } - throw error; } + await fs.promises.mkdir( dir, { recursive: true } ); } - return null; } abstract class BaseImporter extends ImportExportEventEmitter implements Importer { From 5506260c1c96d546ed3030ee2ec15ba9f71074cb Mon Sep 17 00:00:00 2001 From: Ivan Ottinger <25105483+ivan-ottinger@users.noreply.github.com> Date: Thu, 21 May 2026 10:31:36 +0200 Subject: [PATCH 14/14] Move ensureDir blocker to trash instead of unlinking --- apps/cli/lib/import-export/import/importers/importer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 8bc891de9e..6b8ae3b809 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -45,8 +45,8 @@ export async function ensureDir( dir: string ): Promise< void > { try { const stat = await fs.promises.stat( dir ); if ( ! stat.isDirectory() ) { - await fs.promises.unlink( dir ); - console.warn( `ensureDir: removed non-directory blocker at ${ dir }` ); + await trash( dir ); + console.warn( `ensureDir: moved non-directory blocker at ${ dir } to trash` ); } } catch ( e ) { if ( ! isErrnoException( e ) || e.code !== 'ENOENT' ) {