From 782a5b85f10f3ab333db863ccd8afc6e5bc55475 Mon Sep 17 00:00:00 2001 From: Kai Gritun Date: Sun, 26 Jul 2026 01:24:36 -0400 Subject: [PATCH] fix(zip)!: make `includeSources`/`excludeSources` follow standard allowlist behavior (#2114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Eli <88557639+lishaduck@users.noreply.github.com> Co-authored-by: ТΞNSΛI Co-authored-by: Aaron Co-authored-by: Patryk Kuniczak Co-authored-by: Kai Gritun --- packages/wxt/e2e/tests/zip.test.ts | 59 ++++++++++++--- packages/wxt/src/core/resolve-config.ts | 5 +- .../__tests__/picomatch-multiple.test.ts | 71 ------------------- .../wxt/src/core/utils/picomatch-multiple.ts | 38 ---------- .../src/core/utils/testing/fake-objects.ts | 1 + packages/wxt/src/core/zip.ts | 25 +++---- packages/wxt/src/types.ts | 63 ++++++++++++---- 7 files changed, 109 insertions(+), 153 deletions(-) delete mode 100644 packages/wxt/src/core/utils/__tests__/picomatch-multiple.test.ts delete mode 100644 packages/wxt/src/core/utils/picomatch-multiple.ts diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index f64f7e7e..a43f39d3 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -110,7 +110,7 @@ describe('Zipping', () => { expect(await project.pathExists(unzipDir, '.hidden-dir/file')).toBe(false); }); - it('should not zip files inside hidden directories if only the directory is specified', async () => { + it('should zip hidden files and directories when dotSources is enabled', async () => { const project = new TestProject({ name: 'test', version: '1.0.0', @@ -127,17 +127,17 @@ describe('Zipping', () => { await project.zip({ browser: 'firefox', zip: { - includeSources: ['.hidden-dir'], + dotSources: true, }, }); await extract(sourcesZip, { dir: unzipDir }); - expect(await project.pathExists(unzipDir, '.hidden-dir/file')).toBe(false); + expect(await project.pathExists(unzipDir, '.hidden-dir/file')).toBe(true); expect(await project.pathExists(unzipDir, '.hidden-dir/nested/file')).toBe( - false, + true, ); }); - it('should allow zipping hidden files into sources when explicitly listed', async () => { + it('should allow ignoring some hidden files', async () => { const project = new TestProject({ name: 'test', version: '1.0.0', @@ -156,20 +156,57 @@ describe('Zipping', () => { await project.zip({ browser: 'firefox', zip: { - includeSources: ['.env', '.hidden-dir/file', '.hidden-dir/nested/**'], + dotSources: true, + excludeSources: ['.hidden-dir/nested'], }, }); await extract(sourcesZip, { dir: unzipDir }); expect(await project.pathExists(unzipDir, '.env')).toBe(true); expect(await project.pathExists(unzipDir, '.hidden-dir/file')).toBe(true); expect(await project.pathExists(unzipDir, '.hidden-dir/nested/file1')).toBe( - true, + false, ); expect(await project.pathExists(unzipDir, '.hidden-dir/nested/file2')).toBe( - true, + false, ); }); + it('should not include all files when includeSources is provided', async () => { + const project = new TestProject({ + name: 'test', + version: '1.0.0', + }); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {});', + ); + project.addFile('utils/example.ts', 'export const x = 1;'); + project.addFile('secrets/api-key.txt', 'supersecret'); + project.addFile('cache/data.json', '{}'); + const unzipDir = project.resolvePath('.output/test-1.0.0-sources'); + const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); + + await project.zip({ + browser: 'firefox', + zip: { + includeSources: ['entrypoints/**', 'utils/**'], + }, + }); + await extract(sourcesZip, { dir: unzipDir }); + + // Included files should be present + expect( + await project.pathExists(unzipDir, 'entrypoints/background.ts'), + ).toBe(true); + expect(await project.pathExists(unzipDir, 'utils/example.ts')).toBe(true); + + // Non-included files should NOT be present (allowlist behavior) + expect(await project.pathExists(unzipDir, 'secrets/api-key.txt')).toBe( + false, + ); + expect(await project.pathExists(unzipDir, 'cache/data.json')).toBe(false); + }); + it('should exclude skipped entrypoints from respective browser sources zip', async () => { const project = new TestProject({ name: 'test', @@ -188,8 +225,7 @@ describe('Zipping', () => { `export default defineContentScript({ matches: ['*://*/*'], main(ctx) {}, - }); -`, + });`, ); const unzipDir = project.resolvePath('.output/test-1.0.0-sources'); const sourcesZip = project.resolvePath('.output/test-1.0.0-sources.zip'); @@ -289,7 +325,8 @@ describe('Zipping', () => { await project.zip({ zip: { - exclude: ['**/*.json', '!manifest.json'], + // Exclude all JSON files except for ones named `manifest.json` + exclude: ['**/!(manifest).json'], }, }); diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index 55abe21e..5566ff2a 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -304,7 +304,7 @@ function resolveZipConfig( artifactTemplate: '{{name}}-{{packageVersion}}-{{browser}}{{modeSuffix}}.zip', sourcesRoot: root, - includeSources: [], + includeSources: mergedConfig.zip?.includeSources ?? ['**/*'], compressionLevel: 9, ...mergedConfig.zip, zipSources: @@ -314,8 +314,6 @@ function resolveZipConfig( '**/node_modules', // WXT files '**/web-ext.config.ts', - // Hidden files - '**/.*', // Tests '**/__tests__/**', '**/*.+(test|spec).?(c|m)+(j|t)s?(x)', @@ -324,6 +322,7 @@ function resolveZipConfig( // From user ...(mergedConfig.zip?.excludeSources ?? []), ], + dotSources: mergedConfig.zip?.dotSources ?? false, downloadPackages: mergedConfig.zip?.downloadPackages ?? [], downloadedPackagesDir, }; diff --git a/packages/wxt/src/core/utils/__tests__/picomatch-multiple.test.ts b/packages/wxt/src/core/utils/__tests__/picomatch-multiple.test.ts deleted file mode 100644 index 0e154bfc..00000000 --- a/packages/wxt/src/core/utils/__tests__/picomatch-multiple.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { picomatchMultiple } from '../picomatch-multiple'; - -describe('picomatchMultiple', () => { - it('should return false if the pattern array is undefined', () => { - const patterns = undefined; - const search = 'test.json'; - - expect(picomatchMultiple(search, patterns)).toBe(false); - }); - - it('should return false if the pattern array is empty', () => { - const patterns: string[] = []; - const search = 'test.json'; - - expect(picomatchMultiple(search, patterns)).toBe(false); - }); - - it('should return true if the pattern array contains a match', () => { - const patterns = ['test.yml', 'test.json']; - const search = 'test.json'; - - expect(picomatchMultiple(search, patterns)).toBe(true); - }); - - it('should return false if the pattern array does not contain a match', () => { - const patterns = ['test.yml', 'test.json']; - const search = 'test.txt'; - - expect(picomatchMultiple(search, patterns)).toBe(false); - }); - - it('should return false if the pattern matches a negative pattern', () => { - const patterns = ['test.*', '!test.json']; - const search = 'test.json'; - - expect(picomatchMultiple(search, patterns)).toBe(false); - }); - - it('should return false if the pattern matches a negative pattern, regardless of order', () => { - const patterns = ['!test.json', 'test.*']; - const search = 'test.json'; - - expect(picomatchMultiple(search, patterns)).toBe(false); - }); - - it('should support extglob-like extension matching', () => { - const patterns = ['content.[jt]s?(x)']; - - expect(picomatchMultiple('content.ts', patterns)).toBe(true); - expect(picomatchMultiple('content.jsx', patterns)).toBe(true); - expect(picomatchMultiple('content.css', patterns)).toBe(false); - }); - - it('should support nested paths', () => { - const patterns = ['foo/**/*.ts']; - - expect(picomatchMultiple('foo/bar/baz.ts', patterns)).toBe(true); - expect(picomatchMultiple('foo/bar/baz.js', patterns)).toBe(false); - }); - - it('should preserve include/exclude interaction used by zip filtering', () => { - const include = ['special.txt']; - const exclude = ['**/*.txt']; - const search = 'special.txt'; - const shouldInclude = - picomatchMultiple(search, include) || !picomatchMultiple(search, exclude); - - expect(shouldInclude).toBe(true); - }); -}); diff --git a/packages/wxt/src/core/utils/picomatch-multiple.ts b/packages/wxt/src/core/utils/picomatch-multiple.ts deleted file mode 100644 index 8132d679..00000000 --- a/packages/wxt/src/core/utils/picomatch-multiple.ts +++ /dev/null @@ -1,38 +0,0 @@ -import picomatch, { PicomatchOptions } from 'picomatch'; - -/** - * Run [`picomatch`](https://npmjs.com/package/picomatch) against multiple - * patterns. - * - * Supports negated patterns, the order does not matter. If your `search` string - * matches any of the negative patterns, it will return `false`. - * - * @example - * picomatchMultiple('a.json', ['*.json', '!b.json']); // => true - * picomatchMultiple('b.json', ['*.json', '!b.json']); // => false - */ -export function picomatchMultiple( - search: string, - patterns: string[] | undefined, - options?: PicomatchOptions, -): boolean { - if (patterns == null) return false; - - const negatePatterns: string[] = []; - const positivePatterns: string[] = []; - for (const pattern of patterns) { - if (pattern[0] === '!') negatePatterns.push(pattern.slice(1)); - else positivePatterns.push(pattern); - } - - if ( - negatePatterns.some((negatePattern) => - picomatch(negatePattern, options)(search), - ) - ) - return false; - - return positivePatterns.some((positivePattern) => - picomatch(positivePattern, options)(search), - ); -} diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 56f1586c..b0a08587 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -285,6 +285,7 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { artifactTemplate: '{{name}}-{{version}}.zip', includeSources: [], excludeSources: [], + dotSources: false, exclude: [], sourcesRoot: fakeDir(), sourcesTemplate: '{{name}}-sources.zip', diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 2c525252..01b023c1 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -11,7 +11,6 @@ import { registerWxt, wxt } from './wxt'; import JSZip from 'jszip'; import { glob } from 'tinyglobby'; import { normalizePath } from './utils'; -import { picomatchMultiple } from './utils/picomatch-multiple'; /** * Build and zip the extension for distribution. @@ -70,7 +69,7 @@ export async function zip(config?: InlineConfig): Promise { ...skippedEntrypoints.map((entry) => path.relative(wxt.config.zip.sourcesRoot, entry.inputPath), ), - ].map((paths) => paths.replaceAll('\\', '/')); + ].map((paths) => paths.replaceAll('\\', '/')); // TODO: Use normalizePath? await wxt.hooks.callHook('zip:sources:start', wxt); const { overrides, files: downloadedPackages } = await downloadPrivatePackages(); @@ -88,6 +87,7 @@ export async function zip(config?: InlineConfig): Promise { } }, additionalFiles: downloadedPackages, + dot: wxt.config.zip.dotSources, }); zipFiles.push(sourcesZipPath); await wxt.hooks.callHook('zip:sources:done', wxt, sourcesZipPath); @@ -118,22 +118,17 @@ async function zipDir( ) => Promise | string | undefined | void; additionalWork?: (archive: JSZip) => Promise | void; additionalFiles?: string[]; + dot?: boolean; }, ): Promise { const archive = new JSZip(); - const files = ( - await glob(['**/*', ...(options?.include || [])], { - cwd: directory, - // Ignore node_modules, otherwise this glob step takes forever - ignore: ['**/node_modules'], - onlyFiles: true, - expandDirectories: false, - }) - ).filter((relativePath) => { - return ( - picomatchMultiple(relativePath, options?.include) || - !picomatchMultiple(relativePath, options?.exclude) - ); + // includeSources patterns are used directly (defaults to ['**/*'] from config) + // excludeSources patterns are passed to glob's ignore option for efficient filtering + const files = await glob(options?.include ?? ['**/*'], { + cwd: directory, + ignore: options?.exclude ?? [], + onlyFiles: true, + dot: options?.dot, }); const filesToZip = [ ...files, diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 7c8e389d..4ccf6f50 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -250,25 +250,39 @@ export interface InlineConfig { */ sourcesRoot?: string; /** - * [Picomatch](https://www.npmjs.com/package/picomatch) patterns of files to - * include when creating a ZIP of all your source code for Firefox. Patterns - * are relative to your `config.zip.sourcesRoot`. + * [Tinyglobby](https://npmjs.org/tinyglobby) patterns of files to include + * when creating a ZIP of all your source code for Firefox. Patterns are + * relative to your `config.zip.sourcesRoot`. * - * This setting overrides `excludeSources`. So if a file matches both lists, - * it is included in the ZIP. + * Sources ZIP files are created using standard allowlist/blocklist + * behavior: + * + * - You specify a pattern to "include" (via `includeSources`), then a pattern + * to "exclude" from the included files (via `excludeSources`). + * + * By default, this option includes all files except for hidden files and + * directories (files/directories starting with a `.`). + * + * If you want to include hidden files/directories in your sources ZIP, see + * `InlineConfig.zip.dotSources`. * * @example - * [ - * 'coverage', // Include the coverage directory in the `sourcesRoot` - * ]; + * ['entrypoints/**', 'wxt.config.ts', 'package.json', 'tsconfig.json']; */ includeSources?: string[]; /** - * [Picomatch](https://www.npmjs.com/package/picomatch) patterns of files to - * exclude when creating a ZIP of all your source code for Firefox. Patterns - * are relative to your `config.zip.sourcesRoot`. + * [Tinyglobby](https://npmjs.org/tinyglobby) patterns of files to exclude + * when creating a ZIP of all your source code for Firefox. Patterns are + * relative to your `config.zip.sourcesRoot`. * - * Hidden files, node_modules, and tests are ignored by default. + * By default, WXT excludes some files: + * + * - `node_modules` + * - Tests files and directories + * - Output directory + * + * Any values specified in this option will be merged with the ones above - + * you cannot replace the default values, only add to them. * * @example * [ @@ -277,13 +291,31 @@ export interface InlineConfig { */ excludeSources?: string[]; /** - * [Picomatch](https://www.npmjs.com/package/picomatch) patterns of files to - * exclude when zipping the extension. + * Include hidden files/directories in your sources ZIP. + * + * [Tinyglobby](https://npmjs.org/tinyglobby) does not match against files + * and directory that start with a `.` by default. For example, if you need + * to include a `.env` file, you need to set this to `true`, then exclude + * other hidden files/directories in `excludeSources`. + * + * **Be very careful when this is enabled - WXT may include files with + * secrets in your ZIP you did not intend to share with Mozilla or upload to + * other places**. Make sure all hidden files you don't want to include are + * added to `excludeSources`. + * + * @default false + */ + dotSources?: boolean; + /** + * [Tinyglobby](https://npmjs.org/tinyglobby) patterns of files to exclude + * when zipping the extension. * * @example * [ * '**\/*.map', // Exclude all sourcemaps * ]; + * + * @default [ ] */ exclude?: string[]; /** @@ -1533,6 +1565,7 @@ export interface ResolvedConfig { sourcesTemplate: string; includeSources: string[]; excludeSources: string[]; + dotSources: boolean; sourcesRoot: string; downloadedPackagesDir: string; downloadPackages: string[]; @@ -1690,7 +1723,7 @@ export type WxtResolvedUnimportOptions = Partial & { /** * Package management utils built on top of - * [`nypm`](https://www.npmjs.com/package/nypm) + * [`nypm`](https://npmjs.org/package/nypm) */ export interface WxtPackageManager extends Nypm.PackageManager { addDependency: typeof Nypm.addDependency;