fix(zip)!: make includeSources/excludeSources follow standard allowlist behavior (#2114)

Co-authored-by: Eli <88557639+lishaduck@users.noreply.github.com>
Co-authored-by: ТΞNSΛI <tensai@gmx.net>
Co-authored-by: Aaron <aaronklinker1@gmail.com>
Co-authored-by: Patryk Kuniczak <p.kuniczak@gmail.com>
Co-authored-by: Kai Gritun <kai@kaigritun.com>
This commit is contained in:
Kai Gritun
2026-07-26 01:24:36 -04:00
committed by Aaron
parent 961cfa5f2a
commit 782a5b85f1
7 changed files with 109 additions and 153 deletions
+48 -11
View File
@@ -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'],
},
});
+2 -3
View File
@@ -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,
};
@@ -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);
});
});
@@ -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),
);
}
@@ -285,6 +285,7 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
artifactTemplate: '{{name}}-{{version}}.zip',
includeSources: [],
excludeSources: [],
dotSources: false,
exclude: [],
sourcesRoot: fakeDir(),
sourcesTemplate: '{{name}}-sources.zip',
+10 -15
View File
@@ -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<string[]> {
...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<string[]> {
}
},
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> | string | undefined | void;
additionalWork?: (archive: JSZip) => Promise<void> | void;
additionalFiles?: string[];
dot?: boolean;
},
): Promise<void> {
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,
+48 -15
View File
@@ -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<UnimportOptions> & {
/**
* 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;