@openfairygui/functions 0.2.0-alpha.13 → 0.2.0-alpha.15

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.
package/src/publish.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
- type BinaryWriterOptions,
3
2
  BinaryWriter,
3
+ type BinaryWriterOptions,
4
4
  type Component,
5
- type DragonBonesResource,
6
5
  type Document,
6
+ type DragonBonesResource,
7
7
  type FileSystem,
8
8
  type FontResource,
9
9
  type ImageResource,
@@ -11,12 +11,11 @@ import {
11
11
  type MovieClipResource,
12
12
  type Package,
13
13
  ProjectType,
14
- type SpineResource,
15
14
  type SoundResource,
15
+ type SpineResource,
16
16
  type Transform,
17
17
  } from '@openfairygui/core';
18
- import { createTransform } from './utils.js';
19
- import { atlas, type AtlasOptions } from './atlas.js';
18
+ import { type AtlasOptions, atlas } from './atlas.js';
20
19
  import { publishCodeGeneration, resolveProjectBasePath } from './codegen.js';
21
20
  import { formatPluginError, type LoadedPlugin } from './plugins/types.js';
22
21
  import type { AtlasRasterBackend, PublishFileSystem } from './publish/contracts.js';
@@ -26,6 +25,7 @@ import type {
26
25
  PackagePublishArtifactsExtras,
27
26
  RootProjectSettings,
28
27
  } from './shared-types.js';
28
+ import { createTransform } from './utils.js';
29
29
 
30
30
  export interface PublishOptions {
31
31
  /**
@@ -47,7 +47,8 @@ export interface PublishOptions {
47
47
 
48
48
  /**
49
49
  * Raster backend for atlas image compositing.
50
- * If not provided, atlas packing only computes layout (no PNGs generated).
50
+ * Required when a filesystem-backed publish has packable resources.
51
+ * Without a filesystem, publish remains an explicit layout-only transform.
51
52
  */
52
53
  encoder?: AtlasRasterBackend;
53
54
 
@@ -69,8 +70,9 @@ export interface PublishOptions {
69
70
 
70
71
  /**
71
72
  * FileSystem abstraction for writing output files.
72
- * Required for actual file output. Without it, only the Document model
73
- * is updated (atlas layout computed, sprite nodes created).
73
+ * Required for actual file output. Calling publish with a resolved output
74
+ * directory but no filesystem is rejected; omit output entirely for a
75
+ * layout-only transform.
74
76
  */
75
77
  fs?: PublishFileSystem;
76
78
 
@@ -1030,7 +1032,6 @@ async function exportPackageSounds(
1030
1032
  basePath: string | undefined,
1031
1033
  fs: PublishFileSystem,
1032
1034
  readFileRaw: PublishFileSystem['readFileRaw'] | undefined,
1033
- logger: Document['getLogger'] extends () => infer T ? T : never,
1034
1035
  ): Promise<void> {
1035
1036
  const publishedResourceIds = getAnnotatedPublishedResourceIds(pkg);
1036
1037
  if (publishedResourceIds.size === 0) return;
@@ -1039,8 +1040,8 @@ async function exportPackageSounds(
1039
1040
  return isSoundResource(resource) && publishedResourceIds.has(resource.getId());
1040
1041
  });
1041
1042
  if (hasPublishedSound) {
1042
- logger.warn(
1043
- `publish: Sound resources in package "${pkg.getName()}" were not exported because basePath/readFileRaw is unavailable.`,
1043
+ throw new Error(
1044
+ `publish: Sound resources in package "${pkg.getName()}" require basePath and readFileRaw for output.`,
1044
1045
  );
1045
1046
  }
1046
1047
  return;
@@ -1058,7 +1059,7 @@ async function exportPackageSounds(
1058
1059
  const data = await readFileRaw(sourcePath);
1059
1060
  await fs.writeFileRaw(targetPath, data);
1060
1061
  } catch {
1061
- logger.warn(`publish: Could not export sound "${resource.getId()}" from package "${pkg.getName()}".`);
1062
+ throw new Error(`publish: Could not export sound "${resource.getId()}" from package "${pkg.getName()}".`);
1062
1063
  }
1063
1064
  }
1064
1065
  }
@@ -1069,7 +1070,6 @@ async function exportPackageExternalResources(
1069
1070
  basePath: string | undefined,
1070
1071
  fs: PublishFileSystem,
1071
1072
  readFileRaw: PublishFileSystem['readFileRaw'] | undefined,
1072
- logger: Document['getLogger'] extends () => infer T ? T : never,
1073
1073
  ): Promise<void> {
1074
1074
  const exportedResourceIds = getAnnotatedExportedResourceIds(pkg);
1075
1075
  const skeletonDependencyImageIds = getPublishedSkeletonDependencyImageIds(pkg, exportedResourceIds);
@@ -1083,8 +1083,8 @@ async function exportPackageExternalResources(
1083
1083
  );
1084
1084
  });
1085
1085
  if (hasPublishedExternal) {
1086
- logger.warn(
1087
- `publish: External resources in package "${pkg.getName()}" were not exported because basePath/readFileRaw is unavailable.`,
1086
+ throw new Error(
1087
+ `publish: External resources in package "${pkg.getName()}" require basePath and readFileRaw for output.`,
1088
1088
  );
1089
1089
  }
1090
1090
  return;
@@ -1115,7 +1115,7 @@ async function exportPackageExternalResources(
1115
1115
  const data = await readFileRaw(sourcePath);
1116
1116
  await fs.writeFileRaw(targetPath, data);
1117
1117
  } catch {
1118
- logger.warn(
1118
+ throw new Error(
1119
1119
  `publish: Could not export external resource "${resource.getId()}" from package "${pkg.getName()}".`,
1120
1120
  );
1121
1121
  }
@@ -1134,18 +1134,17 @@ async function exportPackageExternalResources(
1134
1134
  * `publishNode()` or `publishBrowser()` through their dedicated entries.
1135
1135
  *
1136
1136
  * ```ts
1137
- * import sharp from 'sharp';
1138
- * const io = new NodeIO();
1139
- * const doc = await io.readProject('./project.fairy');
1137
+ * import { NodeIO } from '@openfairygui/core/node';
1138
+ * import { publishNode } from '@openfairygui/functions/node';
1139
+ * const doc = await new NodeIO().readProject('./project.fairy');
1140
1140
  *
1141
- * await doc.transform(publish({
1141
+ * await publishNode({
1142
+ * document: doc,
1142
1143
  * output: './release/',
1143
1144
  * compressed: true,
1144
- * encoder: sharp,
1145
- * basePath: './assets/',
1145
+ * assetsPath: './assets/',
1146
1146
  * fileExtension: 'bytes',
1147
- * fs: io.createFileSystem(),
1148
- * }));
1147
+ * });
1149
1148
  * ```
1150
1149
  */
1151
1150
  export function publish(options: PublishOptions): Transform {
@@ -1238,6 +1237,30 @@ export function publish(options: PublishOptions): Transform {
1238
1237
  });
1239
1238
 
1240
1239
  const publishPackage = async (plan: ResolvedPackagePublishPlan, writerFs: FileSystem, packageIndex: number) => {
1240
+ if (options.fs && !plan.outputDir) {
1241
+ throw new Error(
1242
+ 'publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.',
1243
+ );
1244
+ }
1245
+
1246
+ if (options.fs) {
1247
+ await options.fs.mkdir(plan.outputDir!);
1248
+ await exportPackageSounds(
1249
+ plan.pkg,
1250
+ plan.outputDir!,
1251
+ options.basePath,
1252
+ options.fs,
1253
+ options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1254
+ );
1255
+ await exportPackageExternalResources(
1256
+ plan.pkg,
1257
+ plan.outputDir!,
1258
+ options.basePath,
1259
+ options.fs,
1260
+ options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1261
+ );
1262
+ }
1263
+
1241
1264
  const atlasRuntimeOptions = resolvePublishAtlasRuntimeOptions(plan.fileExtension);
1242
1265
  await atlas({
1243
1266
  ...plan.atlas,
@@ -1248,20 +1271,14 @@ export function publish(options: PublishOptions): Transform {
1248
1271
  outputPath: options.fs ? plan.outputDir : undefined,
1249
1272
  mkdir: options.fs ? options.fs.mkdir : undefined,
1250
1273
  readFileRaw: options.atlas?.readFileRaw ?? options.fs?.readFileRaw,
1274
+ strictOutput: options.fs !== undefined,
1251
1275
  packages: [plan.pkg.getName()],
1252
1276
  ...atlasRuntimeOptions,
1253
1277
  })(doc);
1254
1278
 
1255
1279
  if (!options.fs) return;
1256
- if (!plan.outputDir) {
1257
- throw new Error(
1258
- 'publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.',
1259
- );
1260
- }
1261
-
1262
- await options.fs.mkdir(plan.outputDir);
1263
1280
 
1264
- const filePath = options.fs.join(plan.outputDir, plan.fileName);
1281
+ const filePath = options.fs.join(plan.outputDir!, plan.fileName);
1265
1282
  const bwOptions: BinaryWriterOptions = {
1266
1283
  compressed: plan.compressed,
1267
1284
  packageIndex,
@@ -1269,22 +1286,6 @@ export function publish(options: PublishOptions): Transform {
1269
1286
 
1270
1287
  const bw = new BinaryWriter(writerFs);
1271
1288
  await bw.write(doc, filePath, bwOptions);
1272
- await exportPackageSounds(
1273
- plan.pkg,
1274
- plan.outputDir,
1275
- options.basePath,
1276
- options.fs,
1277
- options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1278
- logger,
1279
- );
1280
- await exportPackageExternalResources(
1281
- plan.pkg,
1282
- plan.outputDir,
1283
- options.basePath,
1284
- options.fs,
1285
- options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1286
- logger,
1287
- );
1288
1289
 
1289
1290
  logger.info(`publish: Written ${plan.fileName}`);
1290
1291
  };
@@ -1332,8 +1333,15 @@ export function publish(options: PublishOptions): Transform {
1332
1333
  const plans = allPackages.map((pkg) => resolvePackagePublishPlan(pkg, resolved, projectBasePath));
1333
1334
 
1334
1335
  if (!options.fs) {
1336
+ const outputPlan = plans.find((plan) => !!plan.outputDir);
1337
+ if (outputPlan) {
1338
+ throw new Error(
1339
+ `publish: Output for package "${outputPlan.pkg.getName()}" requires a filesystem. ` +
1340
+ 'Omit output and publish paths to run a layout-only transform.',
1341
+ );
1342
+ }
1335
1343
  logger.info(
1336
- `publish: No fs provided — layout computed for ${allPackages.length} package(s), skipping file output.`,
1344
+ `publish: Layout computed for ${allPackages.length} package(s); no output directory was requested.`,
1337
1345
  );
1338
1346
  const noopWriterFs = toBinaryWriterFileSystem(createNoopPublishFs());
1339
1347
  for (const plan of plans) {
package/src/restore.ts CHANGED
@@ -3,9 +3,9 @@ import {
3
3
  type Document,
4
4
  type FileSystem,
5
5
  generateId,
6
+ type Package,
6
7
  ProjectType,
7
8
  ProjectWriter,
8
- type Package,
9
9
  } from '@openfairygui/core';
10
10
 
11
11
  export interface RestoreImageCropInput {
@@ -45,7 +45,8 @@ export interface RestoreFileSystem extends Pick<FileSystem, 'readFile' | 'readFi
45
45
  readdir(path: string): Promise<string[]>;
46
46
  isFile(path: string): Promise<boolean>;
47
47
  resolvePath(path: string): string | Promise<string>;
48
- rm?: (path: string, options?: { recursive?: boolean; force?: boolean }) => Promise<void>;
48
+ rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
49
+ rename(from: string, to: string): Promise<void>;
49
50
  }
50
51
 
51
52
  export interface RestoreOptions {
@@ -178,10 +179,23 @@ const TRANSPARENT_PNG_1X1 = Uint8Array.from([
178
179
  66, 96, 130,
179
180
  ]);
180
181
 
182
+ function assertSafeRestoreSegment(value: string, label: string): void {
183
+ if (!value || value === '.' || value === '..' || value.includes('\0') || /[\\/:]/u.test(value)) {
184
+ throw new Error(`restore: Invalid ${label} "${value}".`);
185
+ }
186
+ }
187
+
181
188
  function normalizeVirtualPath(path: string | undefined): string {
182
- const normalized = (path ?? '').replace(/\\/g, '/').trim();
183
- if (!normalized || normalized === '/') return '';
184
- return normalized.replace(/^\/+/, '').replace(/\/+$/, '');
189
+ const raw = (path ?? '').trim();
190
+ if (!raw || raw === '/') return '';
191
+ if (raw.includes('\0') || raw.startsWith('\\') || raw.startsWith('//') || /^[a-z]:/iu.test(raw)) {
192
+ throw new Error(`restore: Invalid resource path "${raw}".`);
193
+ }
194
+ const segments = raw.replace(/\\/g, '/').split('/').filter(Boolean);
195
+ if (segments.some((segment) => segment === '.' || segment === '..' || segment.includes(':'))) {
196
+ throw new Error(`restore: Invalid resource path "${raw}".`);
197
+ }
198
+ return segments.join('/');
185
199
  }
186
200
 
187
201
  function resourceFileName(resource: RestorableResource): string {
@@ -438,10 +452,10 @@ function normalizeComparablePath(value: string): string {
438
452
  return comparable.toLowerCase();
439
453
  }
440
454
 
441
- function dirname(filePath: string): string {
442
- const trimmed = trimTrailingSlashes(filePath);
443
- const match = trimmed.match(/^(.*)[/\\][^/\\]+$/);
444
- return match?.[1] ?? '';
455
+ function isPathWithin(root: string, candidate: string): boolean {
456
+ const normalizedRoot = normalizeComparablePath(root);
457
+ const normalizedCandidate = normalizeComparablePath(candidate);
458
+ return normalizedCandidate.startsWith(`${normalizedRoot}/`);
445
459
  }
446
460
 
447
461
  function basename(filePath: string): string {
@@ -450,56 +464,56 @@ function basename(filePath: string): string {
450
464
  return match?.[1] ?? '';
451
465
  }
452
466
 
453
- function resolveOutputProjectPath(output: string, fs: Pick<RestoreFileSystem, 'join'>): string {
454
- if (/\.fairy$/i.test(output)) return output;
455
- const normalizedOutput = trimTrailingSlashes(output);
456
- const projectName = basename(normalizedOutput) || 'Restored';
457
- return fs.join(normalizedOutput, `${projectName}.fairy`);
467
+ function normalizeRestoreOutputDir(output: string): string {
468
+ const normalized = trimTrailingSlashes(output);
469
+ const name = basename(normalized);
470
+ if (!normalized || /\.fairy$/i.test(normalized) || !name || name === '.' || name === '..' || /^[a-z]:$/iu.test(name)) {
471
+ throw new Error('restore: Output must be a non-root project directory, not a .fairy file.');
472
+ }
473
+ return normalized;
474
+ }
475
+
476
+ function resolveOutputProjectPath(outputDir: string, fs: Pick<RestoreFileSystem, 'join'>): string {
477
+ return fs.join(outputDir, `${basename(outputDir)}.fairy`);
478
+ }
479
+
480
+ async function resolvePathForContainment(filePath: string, fs: RestoreFileSystem): Promise<string> {
481
+ const missingSegments: string[] = [];
482
+ let existingPath = filePath;
483
+ while (!(await fs.exists(existingPath))) {
484
+ const parentPath = fs.dirname(existingPath);
485
+ if (!parentPath || parentPath === existingPath) {
486
+ return Promise.resolve(fs.resolvePath(filePath));
487
+ }
488
+ missingSegments.unshift(basename(existingPath));
489
+ existingPath = parentPath;
490
+ }
491
+
492
+ const resolvedExistingPath = await Promise.resolve(fs.resolvePath(existingPath));
493
+ return missingSegments.reduce((resolvedPath, segment) => fs.join(resolvedPath, segment), resolvedExistingPath);
458
494
  }
459
495
 
460
- async function prepareRestoreOutputDir(
496
+ async function assertRestoreOutputDir(
461
497
  inputDir: string,
462
498
  outputDir: string,
463
- outputProjectPath: string,
464
499
  fs: RestoreFileSystem,
465
500
  force: boolean,
466
- outputIsProjectFile: boolean,
467
501
  ): Promise<void> {
468
502
  const [resolvedInputDir, resolvedOutputDir] = await Promise.all([
469
- Promise.resolve(fs.resolvePath(inputDir)),
470
- Promise.resolve(fs.resolvePath(outputDir)),
503
+ resolvePathForContainment(inputDir, fs),
504
+ resolvePathForContainment(outputDir, fs),
471
505
  ]);
472
- if (normalizeComparablePath(resolvedInputDir) === normalizeComparablePath(resolvedOutputDir)) {
473
- throw new Error('Restore output directory must be different from the published input directory.');
474
- }
475
-
476
- if (outputIsProjectFile) {
477
- if (!(await fs.exists(outputDir))) {
478
- await fs.mkdir(outputDir);
479
- return;
480
- }
481
- try {
482
- await fs.readdir(outputDir);
483
- } catch {
484
- throw new Error(`Restore output path is not a directory: ${outputDir}`);
485
- }
486
-
487
- if (!(await fs.exists(outputProjectPath))) return;
488
- if (!force) {
489
- throw new Error(`Restore output file already exists: ${outputProjectPath}. Use --force to overwrite it.`);
490
- }
491
- if (!fs.rm) {
492
- throw new Error('Restore output file already exists and the provided fs does not support rm(...).');
493
- }
494
- await fs.rm(outputProjectPath, { recursive: true, force: true });
495
- return;
506
+ const normalizedInputDir = normalizeComparablePath(resolvedInputDir);
507
+ const normalizedOutputDir = normalizeComparablePath(resolvedOutputDir);
508
+ if (
509
+ normalizedInputDir === normalizedOutputDir ||
510
+ isPathWithin(normalizedInputDir, normalizedOutputDir) ||
511
+ isPathWithin(normalizedOutputDir, normalizedInputDir)
512
+ ) {
513
+ throw new Error('Restore output directory must be independent from the published input directory.');
496
514
  }
497
515
 
498
- const exists = await fs.exists(outputDir);
499
- if (!exists) {
500
- await fs.mkdir(outputDir);
501
- return;
502
- }
516
+ if (!(await fs.exists(outputDir))) return;
503
517
 
504
518
  let entries: string[];
505
519
  try {
@@ -512,24 +526,69 @@ async function prepareRestoreOutputDir(
512
526
  if (!force) {
513
527
  throw new Error(`Restore output directory is not empty: ${outputDir}. Use --force to overwrite it.`);
514
528
  }
515
- if (!fs.rm) {
516
- throw new Error('Restore output directory is not empty and the provided fs does not support rm(...).');
529
+ }
530
+
531
+ async function createRestoreStagingDir(outputDir: string, fs: RestoreFileSystem): Promise<string> {
532
+ const parentDir = fs.dirname(outputDir) || '.';
533
+ await fs.mkdir(parentDir);
534
+ for (let attempt = 0; attempt < 8; attempt += 1) {
535
+ const stagingDir = fs.join(parentDir, `.${basename(outputDir)}.restore-${generateId()}`);
536
+ if (await fs.exists(stagingDir)) continue;
537
+ await fs.mkdir(stagingDir);
538
+ return stagingDir;
539
+ }
540
+ throw new Error(`restore: Could not allocate a staging directory beside ${outputDir}.`);
541
+ }
542
+
543
+ async function commitRestoreOutput(
544
+ stagingDir: string,
545
+ outputDir: string,
546
+ fs: RestoreFileSystem,
547
+ ): Promise<string | null> {
548
+ if (!(await fs.exists(outputDir))) {
549
+ await fs.rename(stagingDir, outputDir);
550
+ return null;
551
+ }
552
+
553
+ const parentDir = fs.dirname(outputDir) || '.';
554
+ let backupDir = '';
555
+ for (let attempt = 0; attempt < 8; attempt += 1) {
556
+ const candidate = fs.join(parentDir, `.${basename(outputDir)}.restore-backup-${generateId()}`);
557
+ if (!(await fs.exists(candidate))) {
558
+ backupDir = candidate;
559
+ break;
560
+ }
561
+ }
562
+ if (!backupDir) throw new Error(`restore: Could not allocate a backup directory beside ${outputDir}.`);
563
+
564
+ // ponytail: two-step rename preserves rollback; use a platform directory-exchange primitive if zero reader gap matters.
565
+ await fs.rename(outputDir, backupDir);
566
+ try {
567
+ await fs.rename(stagingDir, outputDir);
568
+ } catch (error) {
569
+ await fs.rename(backupDir, outputDir);
570
+ throw error;
571
+ }
572
+ try {
573
+ await fs.rm(backupDir, { recursive: true, force: true });
574
+ return null;
575
+ } catch {
576
+ return `restore: Previous output retained at ${backupDir}; remove it after checking the restored project.`;
517
577
  }
518
- await fs.rm(outputDir, { recursive: true, force: true });
519
- await fs.mkdir(outputDir);
520
578
  }
521
579
 
522
580
  export async function restore(options: RestoreOptions): Promise<RestoreResult> {
523
581
  const sourceDir = trimTrailingSlashes(options.inputDir);
524
- const outputIsProjectFile = /\.fairy$/i.test(options.output);
525
- const outputProjectPath = resolveOutputProjectPath(options.output, options.fs);
526
- const outputDir = dirname(outputProjectPath) || '.';
527
- await prepareRestoreOutputDir(sourceDir, outputDir, outputProjectPath, options.fs, options.force === true, outputIsProjectFile);
582
+ const outputDir = normalizeRestoreOutputDir(options.output);
583
+ const outputProjectPath = resolveOutputProjectPath(outputDir, options.fs);
584
+ await assertRestoreOutputDir(sourceDir, outputDir, options.fs, options.force === true);
528
585
 
529
586
  const packageFilter = options.packages?.length ? new Set(options.packages) : null;
530
- const candidateBinaryPaths = (await options.fs.readdir(sourceDir))
587
+ const binaryNames = (await options.fs.readdir(sourceDir))
531
588
  .filter((name) => isPublishedBinaryFile(name))
532
- .filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)))
589
+ .filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)));
590
+ for (const binaryName of binaryNames) assertSafeRestoreSegment(binaryName, 'published binary file name');
591
+ const candidateBinaryPaths = binaryNames
533
592
  .map((name) => options.fs.join(sourceDir, name))
534
593
  .sort((left, right) => left.localeCompare(right));
535
594
  const binaryPaths = (await Promise.all(
@@ -543,7 +602,7 @@ export async function restore(options: RestoreOptions): Promise<RestoreResult> {
543
602
  }
544
603
 
545
604
  const restorer = new RestoreWorkflow(options.fs);
546
- return restorer.restore({
605
+ const document = await restorer.prepare({
547
606
  binaryPaths,
548
607
  sourceDir,
549
608
  outputProjectPath,
@@ -551,6 +610,26 @@ export async function restore(options: RestoreOptions): Promise<RestoreResult> {
551
610
  cropImage: options.cropImage,
552
611
  extractImage: options.extractImage,
553
612
  });
613
+ const stagingDir = await createRestoreStagingDir(outputDir, options.fs);
614
+ const stagingProjectPath = options.fs.join(stagingDir, basename(outputProjectPath));
615
+ const warnings: string[] = [];
616
+ try {
617
+ await restorer.write(document, {
618
+ binaryPaths,
619
+ sourceDir,
620
+ outputProjectPath: stagingProjectPath,
621
+ projectType: options.projectType,
622
+ cropImage: options.cropImage,
623
+ extractImage: options.extractImage,
624
+ }, warnings);
625
+ const cleanupWarning = await commitRestoreOutput(stagingDir, outputDir, options.fs);
626
+ if (cleanupWarning) warnings.push(cleanupWarning);
627
+ } catch (error) {
628
+ await options.fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
629
+ throw error;
630
+ }
631
+
632
+ return { document, projectPath: outputProjectPath, warnings };
554
633
  }
555
634
 
556
635
  class RestoreWorkflow {
@@ -560,13 +639,14 @@ class RestoreWorkflow {
560
639
  this._fs = fs;
561
640
  }
562
641
 
563
- async restore(options: RestoreExecutionOptions): Promise<RestoreResult> {
564
- const warnings: string[] = [];
642
+ async prepare(options: RestoreExecutionOptions): Promise<Document> {
565
643
  const reader = new BinaryReader(this._fs);
566
644
  const doc = await reader.readMany(options.binaryPaths);
645
+ this._assertDocumentPaths(doc);
567
646
  this._initializeProjectDefaults(doc, options.projectType);
568
647
  this._initializeImageFileNames(doc);
569
648
  this._initializeLooseResourceFileNames(doc);
649
+ this._assertDocumentPaths(doc);
570
650
  await this._synthesizeLooseSkeletonResources(doc, options.sourceDir);
571
651
  this._initializeRestoredResourceRelations(doc);
572
652
  this._initializePublishedFontTextureIds(doc);
@@ -575,16 +655,30 @@ class RestoreWorkflow {
575
655
  this._initializePublishedTextFontResources(doc);
576
656
  this._initializeDisplayObjectFileNames(doc);
577
657
  this._initializePublishedFontDefaults(doc);
658
+ this._assertDocumentPaths(doc);
659
+ return doc;
660
+ }
578
661
 
662
+ async write(doc: Document, options: RestoreExecutionOptions, warnings: string[]): Promise<void> {
579
663
  const writer = new ProjectWriter(this._fs);
580
664
  await writer.write(doc, options.outputProjectPath);
581
665
  await this._restoreAssets(doc, options, warnings);
666
+ }
582
667
 
583
- return {
584
- document: doc,
585
- projectPath: options.outputProjectPath,
586
- warnings,
587
- };
668
+ private _assertDocumentPaths(doc: Document): void {
669
+ for (const pkg of doc.getRoot().listPackages()) {
670
+ assertSafeRestoreSegment(pkg.getName(), 'package name');
671
+ assertSafeRestoreSegment(pkg.getPublishName() || pkg.getName(), 'package publish name');
672
+ for (const resource of pkg.listResources() as RestorableResource[]) {
673
+ normalizeVirtualPath(resource.getPath?.());
674
+ const branch = resource.getBranch?.() ?? '';
675
+ if (branch) assertSafeRestoreSegment(branch, 'branch name');
676
+ const fileName = resourceFileName(resource);
677
+ if (fileName) assertSafeRestoreSegment(fileName, 'resource file name');
678
+ const publishedFileName = resourcePublishedFileName(resource);
679
+ if (publishedFileName) assertSafeRestoreSegment(publishedFileName, 'published resource file name');
680
+ }
681
+ }
588
682
  }
589
683
 
590
684
  private _initializeProjectDefaults(doc: Document, projectType?: number): void {
@@ -1237,12 +1331,17 @@ class RestoreWorkflow {
1237
1331
 
1238
1332
  private _sourceFileCandidates(pkg: Package, fileName: string, outputFileName = fileName): string[] {
1239
1333
  const publishName = pkg.getPublishName() || pkg.getName();
1240
- return Array.from(new Set([
1334
+ assertSafeRestoreSegment(publishName, 'package publish name');
1335
+ assertSafeRestoreSegment(fileName, 'published source file name');
1336
+ assertSafeRestoreSegment(outputFileName, 'published source file name');
1337
+ const candidates = Array.from(new Set([
1241
1338
  `${publishName}_${fileName}`,
1242
1339
  fileName,
1243
1340
  `${publishName}_${outputFileName}`,
1244
1341
  outputFileName,
1245
1342
  ]));
1343
+ for (const candidate of candidates) assertSafeRestoreSegment(candidate, 'published source file name');
1344
+ return candidates;
1246
1345
  }
1247
1346
 
1248
1347
  private async _resolveLooseSourceFile(pkg: Package, sourceDir: string, outputFileName: string): Promise<string | null> {
@@ -1255,9 +1354,16 @@ class RestoreWorkflow {
1255
1354
  }
1256
1355
 
1257
1356
  private async _resolveSourceFile(sourceDir: string, candidates: string[]): Promise<string | null> {
1357
+ const resolvedSourceDir = await Promise.resolve(this._fs.resolvePath(sourceDir));
1258
1358
  for (const candidate of candidates) {
1359
+ assertSafeRestoreSegment(candidate, 'published source file name');
1259
1360
  const sourcePath = this._fs.join(sourceDir, candidate);
1260
- if (await this._fs.isFile(sourcePath)) return sourcePath;
1361
+ if (!(await this._fs.isFile(sourcePath))) continue;
1362
+ const resolvedSourcePath = await Promise.resolve(this._fs.resolvePath(sourcePath));
1363
+ if (!isPathWithin(resolvedSourceDir, resolvedSourcePath)) {
1364
+ throw new Error(`restore: Published source file resolves outside the input directory: ${candidate}.`);
1365
+ }
1366
+ return resolvedSourcePath;
1261
1367
  }
1262
1368
  return null;
1263
1369
  }
@@ -1270,6 +1376,9 @@ class RestoreWorkflow {
1270
1376
  ): string {
1271
1377
  const basePath = this._fs.dirname(outputProjectPath);
1272
1378
  const branch = resource.getBranch?.() ?? '';
1379
+ assertSafeRestoreSegment(pkg.getName(), 'package name');
1380
+ if (branch) assertSafeRestoreSegment(branch, 'branch name');
1381
+ assertSafeRestoreSegment(fileName, 'resource file name');
1273
1382
  const assetsDir = branch ? `assets_${branch}` : 'assets';
1274
1383
  const virtualPath = normalizeVirtualPath(resource.getPath?.());
1275
1384
  const pkgDir = this._fs.join(basePath, assetsDir, pkg.getName());