diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index a0a959bebd3..8325450b021 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -295,7 +295,16 @@ describe('Extension Update Logic', () => { ), ).rejects.toThrow('Updated extension not found after installation'); - expect(copyExtension).toHaveBeenCalledWith( + // Order matters. A backup taken after the install has already mutated + // the extension directory is exactly as empty as no backup at all, so + // an unordered pair of assertions would not catch a regression. + expect(copyExtension).toHaveBeenNthCalledWith( + 1, + mockExtension.path, + '/tmp/mock-dir', + ); + expect(copyExtension).toHaveBeenNthCalledWith( + 2, '/tmp/mock-dir', mockExtension.path, ); @@ -309,6 +318,27 @@ describe('Extension Update Logic', () => { expect(fs.promises.rm).toHaveBeenCalled(); }); + it('should not restore from the temp dir if the backup itself failed', async () => { + vi.mocked(copyExtension).mockRejectedValueOnce( + new Error('Backup failed'), + ); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow('Backup failed'); + + // The extension directory is still intact at this point. Copying a + // partial temp dir over it would be the corruption the rollback exists + // to prevent, so the only call must be the failed backup attempt. + expect(copyExtension).toHaveBeenCalledTimes(1); + expect(fs.promises.rm).toHaveBeenCalled(); + }); + describe('Integrity Verification', () => { it('should fail update with security alert if integrity is invalid', async () => { vi.mocked( diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 0d751fd9c5e..9c0f46353cd 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -100,7 +100,16 @@ export async function updateExtension( const originalVersion = extension.version; const tempDir = await ExtensionStorage.createTmpDir(); + // Tracks whether tempDir actually holds a copy of the current installation. + // The rollback below must not restore from tempDir unless it does, or a + // failed backup would overwrite an intact extension with a partial copy. + let backedUp = false; try { + // Back up the current installation before anything mutates it. Without + // this, tempDir stays empty and the rollback in the catch block restores + // nothing. + await copyExtension(extension.path, tempDir); + backedUp = true; const previousExtensionConfig = await extensionManager.loadExtensionConfig( extension.path, ); @@ -142,7 +151,9 @@ export async function updateExtension( type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); - await copyExtension(tempDir, extension.path); + if (backedUp) { + await copyExtension(tempDir, extension.path); + } throw e; } finally { await fs.promises.rm(tempDir, { recursive: true, force: true });