@zeropress/build-core 0.6.13 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,51 @@
1
- import { validateThemeFiles } from '@zeropress/theme-validator';
1
+ import {
2
+ validateThemeFiles,
3
+ validateThemePackageLimits,
4
+ } from '@zeropress/theme-validator';
5
+ import { formatThemeValidationFailure } from './format-theme-validation.js';
2
6
 
3
7
  const TEXT_FILE_EXTENSIONS = new Set(['.html', '.json', '.css', '.js', '.txt', '.svg', '.xml']);
4
8
 
5
9
  export async function loadThemePackageFromDir(themeDir) {
6
10
  const fs = await import('node:fs/promises');
11
+ const { constants: fsConstants } = await import('node:fs');
7
12
  const path = await import('node:path');
13
+ if (typeof themeDir !== 'string' || !themeDir.trim()) {
14
+ throw new Error('Theme directory must be a non-empty path');
15
+ }
16
+ const requestedThemeDir = path.resolve(themeDir);
8
17
 
9
- const fileMap = new Map();
10
- await readThemeDir(fs, path, themeDir, themeDir, fileMap);
18
+ const rootStat = await fs.lstat(requestedThemeDir);
19
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
20
+ throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
21
+ }
22
+ const canonicalThemeDir = await fs.realpath(requestedThemeDir);
23
+ const confirmedRootStat = await fs.lstat(requestedThemeDir);
24
+ if (confirmedRootStat.isSymbolicLink() || !confirmedRootStat.isDirectory()) {
25
+ throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
26
+ }
11
27
 
12
- const validation = await validateThemeFiles(fileMap);
28
+ const fileMap = new Map();
29
+ const packageState = {
30
+ entryCount: 0,
31
+ fileSizes: new Map(),
32
+ pathEntries: [],
33
+ seenCanonicalPaths: new Map(),
34
+ };
35
+ await readThemeDir(
36
+ fs,
37
+ fsConstants,
38
+ path,
39
+ canonicalThemeDir,
40
+ canonicalThemeDir,
41
+ fileMap,
42
+ packageState,
43
+ );
44
+
45
+ const validation = await validateThemeFiles(fileMap, {
46
+ entryCount: packageState.entryCount,
47
+ pathEntries: packageState.pathEntries,
48
+ });
13
49
  if (!validation.ok) {
14
50
  throw new Error(formatThemeValidationFailure(validation));
15
51
  }
@@ -58,82 +94,112 @@ export async function loadThemePackageFromDir(themeDir) {
58
94
  };
59
95
  }
60
96
 
61
- function formatThemeValidationFailure(validation) {
62
- const blocks = [
63
- [
64
- 'Theme validation failed',
65
- `Errors: ${validation.errors.length}`,
66
- `Checked files: ${validation.checkedFiles}`,
67
- ].join('\n'),
68
- ...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
69
- ];
70
- return blocks.join('\n\n');
71
- }
72
-
73
- function formatThemeValidationIssue(issue) {
74
- if (!issue) {
75
- return 'Reason: Unknown error';
76
- }
77
-
78
- const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
79
- const location = splitIssuePath(issue.path);
80
- if (location.file) {
81
- lines.push(`File: ${location.file}`);
82
- }
83
- if (location.path) {
84
- lines.push(`Path: ${location.path}`);
85
- }
86
- if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
87
- lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
88
- }
89
- if (issue.category) {
90
- lines.push(`Category: ${issue.category}`);
91
- }
92
- lines.push(`Reason: ${issue.message || 'Unknown error'}`);
93
- if (issue.snippet) {
94
- const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
95
- lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
96
- }
97
- if (issue.hint) {
98
- lines.push('', 'Hint:', issue.hint);
99
- }
100
-
101
- return lines.join('\n');
102
- }
103
-
104
- function splitIssuePath(issuePath) {
105
- const normalizedPath = String(issuePath || '');
106
- if (normalizedPath.startsWith('theme.json.')) {
107
- return {
108
- file: 'theme.json',
109
- path: normalizedPath.slice('theme.json.'.length),
110
- };
97
+ async function readThemeDir(fs, fsConstants, path, rootDir, currentDir, fileMap, packageState) {
98
+ const entries = [];
99
+ const directory = await fs.opendir(currentDir);
100
+ for await (const entry of directory) {
101
+ packageState.entryCount += 1;
102
+ assertThemePackageLimits(packageState);
103
+ entries.push(entry);
111
104
  }
112
-
113
- return { file: normalizedPath, path: '' };
114
- }
115
-
116
- async function readThemeDir(fs, path, rootDir, currentDir, fileMap) {
117
- const entries = await fs.readdir(currentDir, { withFileTypes: true });
105
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
118
106
 
119
107
  for (const entry of entries) {
120
108
  const fullPath = path.join(currentDir, entry.name);
121
- const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, '/');
109
+ const rawRelativePath = path.relative(rootDir, fullPath);
110
+ const relativePath = rawRelativePath.replace(/\\/g, '/');
111
+ if (entry.name.includes('\\')) {
112
+ throw createThemePathError(
113
+ 'PATH_ESCAPE',
114
+ rawRelativePath,
115
+ `Backslashes are not allowed in theme package paths: ${rawRelativePath}`,
116
+ packageState.fileSizes.size,
117
+ );
118
+ }
119
+ const collisionKey = relativePath.normalize('NFC').toLowerCase();
120
+ const existingPath = packageState.seenCanonicalPaths.get(collisionKey);
121
+ if (existingPath !== undefined) {
122
+ throw createThemePathError(
123
+ 'THEME_PATH_COLLISION',
124
+ relativePath,
125
+ `Theme package path collision: '${relativePath}' conflicts with '${existingPath}' after NFC and case normalization`,
126
+ packageState.fileSizes.size,
127
+ );
128
+ }
129
+ packageState.seenCanonicalPaths.set(collisionKey, relativePath);
130
+ const stat = await fs.lstat(fullPath);
131
+ const isSymlink = stat.isSymbolicLink();
132
+ packageState.pathEntries.push({ path: relativePath, isSymlink });
133
+
134
+ if (isSymlink) {
135
+ continue;
136
+ }
122
137
 
123
- if (entry.isDirectory()) {
124
- await readThemeDir(fs, path, rootDir, fullPath, fileMap);
138
+ if (stat.isDirectory()) {
139
+ await readThemeDir(fs, fsConstants, path, rootDir, fullPath, fileMap, packageState);
125
140
  continue;
126
141
  }
127
142
 
128
- const ext = path.extname(entry.name).toLowerCase();
129
- if (TEXT_FILE_EXTENSIONS.has(ext)) {
130
- fileMap.set(relativePath, await fs.readFile(fullPath, 'utf8'));
131
- } else {
132
- fileMap.set(relativePath, new Uint8Array(await fs.readFile(fullPath)));
143
+ if (!stat.isFile()) {
144
+ continue;
145
+ }
146
+
147
+ const noFollow = fsConstants.O_NOFOLLOW || 0;
148
+ let handle;
149
+ try {
150
+ handle = await fs.open(fullPath, fsConstants.O_RDONLY | noFollow);
151
+ const openedStat = await handle.stat();
152
+ if (!openedStat.isFile()) {
153
+ continue;
154
+ }
155
+ packageState.fileSizes.set(relativePath, openedStat.size);
156
+ assertThemePackageLimits(packageState);
157
+
158
+ const ext = path.extname(entry.name).toLowerCase();
159
+ if (TEXT_FILE_EXTENSIONS.has(ext)) {
160
+ fileMap.set(relativePath, await handle.readFile('utf8'));
161
+ } else {
162
+ fileMap.set(relativePath, new Uint8Array(await handle.readFile()));
163
+ }
164
+ } catch (error) {
165
+ if (error?.code === 'ELOOP') {
166
+ packageState.pathEntries[packageState.pathEntries.length - 1].isSymlink = true;
167
+ continue;
168
+ }
169
+ throw error;
170
+ } finally {
171
+ await handle?.close();
133
172
  }
134
173
  }
135
174
  }
136
175
 
176
+ function createThemePathError(code, filePath, message, checkedFiles) {
177
+ return new Error(formatThemeValidationFailure({
178
+ errors: [{
179
+ code,
180
+ path: filePath,
181
+ message,
182
+ severity: 'error',
183
+ category: 'theme_package_paths',
184
+ }],
185
+ checkedFiles,
186
+ }));
187
+ }
188
+
189
+ function assertThemePackageLimits(packageState) {
190
+ const errors = validateThemePackageLimits(packageState.fileSizes, {
191
+ entryCount: packageState.entryCount,
192
+ });
193
+ if (errors.length === 0) {
194
+ return;
195
+ }
196
+
197
+ throw new Error(formatThemeValidationFailure({
198
+ errors,
199
+ checkedFiles: packageState.fileSizes.size,
200
+ }));
201
+ }
202
+
137
203
  function toUint8Array(value) {
138
204
  if (value instanceof Uint8Array) {
139
205
  return value;