Skill Tracer
Skills › Archify › archify › portable-path.mjs

portable-path.mjs

408 lines · archify/renderers/shared/portable-path.mjs at a406594

1const PORTABLE_PATH_PROFILES = new Set(['output', 'repo', 'archive']);
2const WINDOWS_SAFE_PROFILES = new Set(['output', 'archive']);
3const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
4const WINDOWS_SHORT_NAME = /~[1-9][0-9]*(?:\.|$)/iu;
5const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
6const CONTROL_CHARACTER = /\p{Cc}/u;
7const WINDOWS_INVALID_CHARACTER = /[<>"|?*]/u;
8const MAX_SEMANTIC_VARIANTS = 32;
9
10export class PortablePathError extends Error {
11 constructor(message, {
12 code,
13 reason,
14 value,
15 profile,
16 segment,
17 segmentIndex,
18 character,
19 index,
20 conflictIndex,
21 conflictValue,
22 collisionKind,
23 semantics,
24 pathPart,
25 conflictPathPart,
26 utf8Bytes,
27 utf16CodeUnits,
28 limit,
29 } = {}) {
30 super(message);
31 this.name = 'PortablePathError';
32 this.code = code;
33 this.reason = reason;
34 this.value = value;
35 this.profile = profile;
36 if (segment !== undefined) this.segment = segment;
37 if (segmentIndex !== undefined) this.segmentIndex = segmentIndex;
38 if (character !== undefined) this.character = character;
39 if (index !== undefined) this.index = index;
40 if (conflictIndex !== undefined) this.conflictIndex = conflictIndex;
41 if (conflictValue !== undefined) this.conflictValue = conflictValue;
42 if (collisionKind !== undefined) this.collisionKind = collisionKind;
43 if (semantics !== undefined) this.semantics = semantics;
44 if (pathPart !== undefined) this.pathPart = pathPart;
45 if (conflictPathPart !== undefined) this.conflictPathPart = conflictPathPart;
46 if (utf8Bytes !== undefined) this.utf8Bytes = utf8Bytes;
47 if (utf16CodeUnits !== undefined) this.utf16CodeUnits = utf16CodeUnits;
48 if (limit !== undefined) this.limit = limit;
49 }
52function pathError(value, profile, reason, message, details = {}) {
53 return new PortablePathError(message, {
54 code: `portable-path/${reason}`,
55 reason,
56 value,
57 profile,
58 ...details,
59 });
62function assertProfile(profile) {
63 if (PORTABLE_PATH_PROFILES.has(profile)) return;
64 throw pathError(
65 undefined,
66 profile,
67 'profile',
68 `Portable path profile must be one of: ${[...PORTABLE_PATH_PROFILES].join(', ')}.`,
69 );
72export function validatePortablePath(value, options = {}) {
73 const profile = options?.profile;
74 assertProfile(profile);
76 if (typeof value !== 'string') {
77 throw pathError(value, profile, 'type', 'Portable path must be a string.');
78 }
79 if (value.length === 0) {
80 throw pathError(value, profile, 'empty', 'Portable path must not be empty.');
81 }
82 if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('/') || value.startsWith('\\')) {
83 throw pathError(value, profile, 'absolute', 'Portable path must be relative.');
84 }
85 if (/^[A-Za-z]:/u.test(value)) {
86 throw pathError(
87 value,
88 profile,
89 'drive-relative',
90 'Portable path must not use a drive-relative Windows path.',
91 );
92 }
93 if (URI_SCHEME.test(value)) {
94 throw pathError(value, profile, 'uri', 'Portable path must not be a URI.');
95 }
96 if (value.includes('\\')) {
97 throw pathError(
98 value,
99 profile,
100 'backslash',
101 'Portable path must use forward slashes as separators.',
102 );
103 }
105 const segments = value.split('/');
106 for (const [segmentIndex, segment] of segments.entries()) {
107 const segmentDetails = { segment, segmentIndex };
108 if (segment.length === 0) {
109 throw pathError(
110 value,
111 profile,
112 'empty-segment',
113 'Portable path must not contain empty segments.',
114 segmentDetails,
115 );
116 }
117 if (segment === '.' || segment === '..') {
118 throw pathError(
119 value,
120 profile,
121 'dot-segment',
122 'Portable path must not contain dot segments.',
123 segmentDetails,
124 );
125 }
126 const controlMatch = segment.match(CONTROL_CHARACTER);
127 if (controlMatch) {
128 throw pathError(
129 value,
130 profile,
131 'control',
132 'Portable path must not contain control characters.',
133 { ...segmentDetails, character: controlMatch[0] },
134 );
135 }
136 const surrogateMatch = segment.match(/[\uD800-\uDFFF]/u);
137 if (surrogateMatch) {
138 throw pathError(
139 value,
140 profile,
141 'unpaired-surrogate',
142 'Portable path must not contain unpaired UTF-16 surrogates.',
143 { ...segmentDetails, character: surrogateMatch[0] },
144 );
145 }
147 if (!WINDOWS_SAFE_PROFILES.has(profile)) continue;
148 if (segment.includes(':')) {
149 throw pathError(
150 value,
151 profile,
152 'windows-ads',
153 'Portable path must not select a Windows alternate data stream.',
154 { ...segmentDetails, character: ':' },
155 );
156 }
157 const invalidMatch = segment.match(WINDOWS_INVALID_CHARACTER);
158 if (invalidMatch) {
159 throw pathError(
160 value,
161 profile,
162 'windows-invalid-character',
163 'Portable path contains a character that is invalid in Windows file names.',
164 { ...segmentDetails, character: invalidMatch[0] },
165 );
166 }
167 if (/[. ]$/u.test(segment)) {
168 throw pathError(
169 value,
170 profile,
171 'windows-trailing-dot-space',
172 'Portable path segments must not end with a dot or space.',
173 segmentDetails,
174 );
175 }
176 if (WINDOWS_RESERVED_NAME.test(segment)) {
177 throw pathError(
178 value,
179 profile,
180 'windows-reserved-name',
181 'Portable path must not use a reserved Windows device name.',
182 segmentDetails,
183 );
184 }
185 if (WINDOWS_SHORT_NAME.test(segment)) {
186 throw pathError(
187 value,
188 profile,
189 'windows-short-name',
190 'Portable path must not use a Windows 8.3 short-name shape.',
191 segmentDetails,
192 );
193 }
194 const utf8Bytes = Buffer.byteLength(segment, 'utf8');
195 const utf16CodeUnits = segment.length;
196 if (utf8Bytes > 255 || utf16CodeUnits > 255) {
197 throw pathError(
198 value,
199 profile,
200 'component-too-long',
201 'Portable path segments must fit both UTF-8 and UTF-16 filesystem component limits.',
202 {
203 ...segmentDetails,
204 utf8Bytes,
205 utf16CodeUnits,
206 limit: 255,
207 },
208 );
209 }
210 }
212 return value;
215function collisionError(values, profile, index, conflictIndex, reason, details = {}) {
216 return new PortablePathError(
217 `Portable paths at indexes ${conflictIndex} and ${index} collide under portable filesystem semantics.`,
218 {
219 code: 'portable-path/collision',
220 reason,
221 value: values[index],
222 profile,
223 index,
224 conflictIndex,
225 conflictValue: values[conflictIndex],
226 ...details,
227 },
228 );
231function createSemanticIndex() {
232 return new Map();
235function semanticEnvelope(value, profile, index) {
236 const variants = [value];
237 const seen = new Set(variants);
238 const transforms = [
239 (candidate) => candidate.normalize('NFC'),
240 (candidate) => candidate.normalize('NFD'),
241 // path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
242 (candidate) => candidate.toLocaleLowerCase('en-US'),
243 // path-contract-allow: portable-logical-path -- Archive names require conservative case-collision closure.
244 (candidate) => candidate.toLocaleUpperCase('en-US'),
245 ];
247 for (let cursor = 0; cursor < variants.length; cursor += 1) {
248 for (const transform of transforms) {
249 const transformed = transform(variants[cursor]);
250 if (seen.has(transformed)) continue;
251 if (variants.length >= MAX_SEMANTIC_VARIANTS) {
252 throw pathError(
253 value,
254 profile,
255 'semantic-expansion',
256 'Portable path semantic comparison exceeded its bounded Unicode expansion.',
257 { index, limit: MAX_SEMANTIC_VARIANTS },
258 );
259 }
260 seen.add(transformed);
261 variants.push(transformed);
262 }
263 }
265 return variants;
268function classifySemanticCollision(value, conflictValue) {
269 if (value === conflictValue) return 'exact';
270 if (value.normalize('NFC') === conflictValue.normalize('NFC')) return 'normalization';
272 const directLowerMatch = value.toLocaleLowerCase('en-US')
273 === conflictValue.toLocaleLowerCase('en-US');
274 const directUpperMatch = value.toLocaleUpperCase('en-US')
275 === conflictValue.toLocaleUpperCase('en-US');
276 return directLowerMatch || directUpperMatch ? 'case' : 'case-and-normalization';
279function findSemanticCollision(semanticIndex, value, profile, sourceIndex) {
280 const variants = semanticEnvelope(value, profile, sourceIndex);
281 for (const variant of variants) {
282 const entry = semanticIndex.get(variant);
283 if (entry) {
284 return {
285 entry,
286 variants,
287 semantics: classifySemanticCollision(value, entry.value),
288 };
289 }
290 }
291 return { entry: null, variants, semantics: null };
294function rememberSemanticEntry(semanticIndex, entry, variants) {
295 for (const variant of variants) {
296 if (!semanticIndex.has(variant)) semanticIndex.set(variant, entry);
297 }
300export function validatePortablePathSet(values, options = {}) {
301 const profile = options?.profile;
302 assertProfile(profile);
303 if (!Array.isArray(values)) {
304 throw pathError(values, profile, 'set-type', 'Portable path set must be an array.');
305 }
307 const leafEntries = createSemanticIndex();
308 const directoryEntries = createSemanticIndex();
310 for (const [index, value] of values.entries()) {
311 try {
312 validatePortablePath(value, { profile });
313 } catch (error) {
314 if (error instanceof PortablePathError && error.index === undefined) error.index = index;
315 throw error;
316 }
318 const leafCollision = findSemanticCollision(leafEntries, value, profile, index);
319 if (leafCollision.entry) {
320 const conflictIndex = leafCollision.entry.index;
321 throw collisionError(
322 values,
323 profile,
324 index,
325 conflictIndex,
326 leafCollision.semantics === 'exact' ? 'duplicate' : leafCollision.semantics,
327 {
328 collisionKind: 'entry',
329 semantics: leafCollision.semantics,
330 pathPart: value,
331 conflictPathPart: leafCollision.entry.value,
332 },
333 );
334 }
336 const segments = value.split('/');
337 const prefixes = segments.slice(0, -1).map((_, prefixIndex) => (
338 segments.slice(0, prefixIndex + 1).join('/')
339 ));
340 for (const prefix of prefixes) {
341 const directoryCollision = findSemanticCollision(directoryEntries, prefix, profile, index);
342 if (directoryCollision.entry && directoryCollision.semantics !== 'exact') {
343 throw collisionError(
344 values,
345 profile,
346 index,
347 directoryCollision.entry.index,
348 `directory-${directoryCollision.semantics}`,
349 {
350 collisionKind: 'directory-spelling',
351 semantics: directoryCollision.semantics,
352 pathPart: prefix,
353 conflictPathPart: directoryCollision.entry.value,
354 },
355 );
356 }
358 const fileCollision = findSemanticCollision(leafEntries, prefix, profile, index);
359 if (fileCollision.entry) {
360 throw collisionError(
361 values,
362 profile,
363 index,
364 fileCollision.entry.index,
365 fileCollision.semantics === 'exact'
366 ? 'tree-file'
367 : `tree-file-${fileCollision.semantics}`,
368 {
369 collisionKind: 'tree-file',
370 semantics: fileCollision.semantics,
371 pathPart: prefix,
372 conflictPathPart: fileCollision.entry.value,
373 },
374 );
375 }
376 }
378 const directoryCollision = findSemanticCollision(directoryEntries, value, profile, index);
379 if (directoryCollision.entry) {
380 throw collisionError(
381 values,
382 profile,
383 index,
384 directoryCollision.entry.index,
385 directoryCollision.semantics === 'exact'
386 ? 'tree-file'
387 : `tree-file-${directoryCollision.semantics}`,
388 {
389 collisionKind: 'tree-file',
390 semantics: directoryCollision.semantics,
391 pathPart: value,
392 conflictPathPart: directoryCollision.entry.value,
393 },
394 );
395 }
397 rememberSemanticEntry(leafEntries, { index, value }, leafCollision.variants);
398 for (const prefix of prefixes) {
399 rememberSemanticEntry(
400 directoryEntries,
401 { index, value: prefix },
402 semanticEnvelope(prefix, profile, index),
403 );
404 }
405 }
407 return values;