@kubb/studio 5.11.1 → 5.11.2

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/README.md CHANGED
@@ -85,6 +85,10 @@ running in a developer's project and a container running a fixed plugin set.
85
85
  the agent is a sandbox and carries its slug and Studio's version. From then on the agent pings every
86
86
  30 seconds, and Studio schedules one job at a time on the socket.
87
87
 
88
+ With `allowRead`, the connect payload also includes a kept copy of the current `output.path` files.
89
+ Studio can browse these files through `readFiles` before running a generation. Sandbox agents have
90
+ no disk output, and output paths outside the project root are excluded.
91
+
88
92
  Studio ends a socket on purpose with one of these close codes:
89
93
 
90
94
  | Code | Meaning | `connectAgent` |
@@ -108,7 +112,7 @@ Every permission is off by default, and each covers one trust boundary:
108
112
  | `allowWrite` | Generated files are written to disk. Off means they exist only in memory and stream to Studio. |
109
113
  | `allowExec` | The formatter, the linter, and `output.postGenerate` run as child processes. |
110
114
  | `allowConfigEdit` | Studio may change plugin options in `kubb.config.ts`. |
111
- | `allowRead` | Studio may read back the files a generation produced. |
115
+ | `allowRead` | Studio may read existing output files and the files a generation produced. |
112
116
  | `allowReadAst` | Studio may read back the parsed spec (the Kubb AST). |
113
117
 
114
118
  A sandbox never writes or edits config, and always allows reads.
package/dist/index.cjs CHANGED
@@ -140,6 +140,27 @@ async function read(path) {
140
140
  if (runtime.isBun) return Bun.file(path).text();
141
141
  return (0, node_fs_promises.readFile)(path, { encoding: "utf8" });
142
142
  }
143
+ /**
144
+ * Resolves to `true` when `path` is `parent` itself or nested inside it. Both sides are resolved
145
+ * to absolute paths first, so relative and `..`-containing inputs compare correctly.
146
+ *
147
+ * Guards a destructive or an out-of-tree operation: before wiping an output directory, check that
148
+ * it does not contain the project root, and before loading a path a caller supplied, check that it
149
+ * did not escape the directory it is allowed to read from.
150
+ *
151
+ * @example
152
+ * isPathInside('./src/gen', '.') // true — nested inside the root
153
+ * isPathInside('.', '.') // true — the same directory counts as inside
154
+ * isPathInside('.', './src/gen') // false — the root is not inside its own output
155
+ * isPathInside('../other', '.') // false — escapes the root
156
+ */
157
+ function isPathInside(path, parent) {
158
+ const resolvedPath = (0, node_path.resolve)(path);
159
+ const resolvedParent = (0, node_path.resolve)(parent);
160
+ if (resolvedPath === resolvedParent) return true;
161
+ const rel = (0, node_path.relative)(resolvedParent, resolvedPath);
162
+ return rel !== "" && !rel.startsWith("..") && !(0, node_path.isAbsolute)(rel);
163
+ }
143
164
  //#endregion
144
165
  //#region ../../internals/utils/src/promise.ts
145
166
  /**
@@ -178,7 +199,7 @@ function getElapsedMs(hrStart) {
178
199
  }
179
200
  //#endregion
180
201
  //#region package.json
181
- var version = "5.11.1";
202
+ var version = "5.11.2";
182
203
  //#endregion
183
204
  //#region src/resolveConfig.ts
184
205
  /**
@@ -483,6 +504,33 @@ async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PUR
483
504
  })];
484
505
  }
485
506
  /**
507
+ * Throws `KUBB_PATH_TRAVERSAL` when a plugin would write outside the config's output folder to a path
508
+ * the disk config does not set. Studio can change `output.path`, also inside `override`, and add
509
+ * plugins, so without this check any member of the Studio organization could write any file the agent
510
+ * can reach, including `node_modules` code the next run imports. A path from the config file stays
511
+ * allowed, since a monorepo may write outside its root.
512
+ */
513
+ function assertOutputPaths({ plugins, diskPlugins, root, outputPath }) {
514
+ const base = node_path.default.resolve(root, outputPath);
515
+ const targetsOf = (plugin) => {
516
+ const { output, override } = plugin.options ?? {};
517
+ return [output, ...Array.isArray(override) ? override.map((entry) => entry?.options?.output) : []].flatMap((entry) => typeof entry?.path === "string" ? [node_path.default.resolve(base, entry.path)] : []);
518
+ };
519
+ const diskTargets = new Set(diskPlugins.flatMap(targetsOf));
520
+ for (const plugin of plugins) {
521
+ const target = targetsOf(plugin).find((resolved) => !diskTargets.has(resolved) && !isPathInside(resolved, base));
522
+ if (!target) continue;
523
+ throw new _kubb_core.Diagnostics.Error({
524
+ code: _kubb_core.Diagnostics.code.pathTraversal,
525
+ severity: "error",
526
+ message: `Plugin "${plugin.name}" would write to "${target}", outside the output folder "${base}". Studio can only set an output path inside it.`,
527
+ help: "Choose an output path inside the output folder, or set this path in the config file.",
528
+ location: { kind: "config" },
529
+ plugin: plugin.name
530
+ });
531
+ }
532
+ }
533
+ /**
486
534
  * Re-invokes the factory behind a disk adapter or parser with Studio's options merged over its own.
487
535
  * An instance carries live functions (`parse`, `print`, ...) that cannot survive JSON, so Studio's
488
536
  * options are a patch, not a replacement, and the factory is found from the disk instance itself: the
@@ -1448,7 +1496,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1448
1496
  /**
1449
1497
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1450
1498
  */
1451
- async function keep({ jobId, source, files, maxSetMb }) {
1499
+ async function keep({ jobId, source, files, maxSetMb, normalize = source === require_protocol.FileSource.OUTPUT }) {
1452
1500
  const maxSetBytes = maxSetMb * MB;
1453
1501
  const hashes = {};
1454
1502
  let bytes = 0;
@@ -1459,8 +1507,8 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1459
1507
  if (path.split("/").includes("..")) return;
1460
1508
  const content = await files.storage.readItem((0, node_path.resolve)(files.root, path));
1461
1509
  if (content === null) return;
1462
- if (source === require_protocol.FileSource.OUTPUT && !content.trim()) return;
1463
- hashes[path] = hashOf(source === require_protocol.FileSource.OUTPUT ? `${content.trim()}\n` : content);
1510
+ if (normalize && !content.trim()) return;
1511
+ hashes[path] = hashOf(normalize ? `${content.trim()}\n` : content);
1464
1512
  bytes += Buffer.byteLength(content);
1465
1513
  if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1466
1514
  }
@@ -1924,8 +1972,12 @@ var Agent = class extends capnweb.RpcTarget {
1924
1972
  this.#readConfigFileView(source),
1925
1973
  this.#installedExtensions(source)
1926
1974
  ]);
1975
+ const output = await this.#readDiskOutput(config).catch(async (error) => {
1976
+ await this.#warn({ message: `Could not read the output directory: ${getErrorMessage(error)}` });
1977
+ });
1927
1978
  this.#session.connected.resolve(info);
1928
1979
  return {
1980
+ output,
1929
1981
  versions: {
1930
1982
  kubb: version,
1931
1983
  agent: version$1
@@ -1962,6 +2014,32 @@ var Agent = class extends capnweb.RpcTarget {
1962
2014
  permissions: this.#permissions
1963
2015
  };
1964
2016
  }
2017
+ async #readDiskOutput(config) {
2018
+ if (this.#sandbox || !this.#permissions.allowRead || this.#session.active) return void 0;
2019
+ const files = await listDisk({
2020
+ root: this.#options.root,
2021
+ outputPath: config.output.path,
2022
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2023
+ });
2024
+ if (!files) return void 0;
2025
+ const jobId = (0, node_crypto.randomUUID)();
2026
+ const output = await this.#generations.keep({
2027
+ jobId,
2028
+ source: require_protocol.FileSource.OUTPUT,
2029
+ files,
2030
+ maxSetMb: this.#limits.maxSnapshotMb,
2031
+ normalize: false
2032
+ });
2033
+ await this.#generations.add({
2034
+ jobId,
2035
+ output
2036
+ });
2037
+ return {
2038
+ jobId,
2039
+ files: output.paths,
2040
+ hashes: output.hashes
2041
+ };
2042
+ }
1965
2043
  startGeneration(input) {
1966
2044
  const stream = createGenerationStream(this.#options.hooks, input.jobId);
1967
2045
  const controller = new AbortController();
@@ -2014,6 +2092,7 @@ var Agent = class extends capnweb.RpcTarget {
2014
2092
  settled.resolve();
2015
2093
  }
2016
2094
  }
2095
+ /** Merges Studio's config over the disk config, refuses output paths outside the output folder, then generates in `root`. */
2017
2096
  async #generate({ input, root, signal }) {
2018
2097
  const { jobId, config: patch } = input;
2019
2098
  const studioInput = this.#sandbox ? patch?.input : void 0;
@@ -2024,6 +2103,14 @@ var Agent = class extends capnweb.RpcTarget {
2024
2103
  diskPlugins: config.plugins,
2025
2104
  studioPlugins: patch?.plugins,
2026
2105
  extensions
2106
+ }).then((merged) => {
2107
+ assertOutputPaths({
2108
+ plugins: merged ?? [],
2109
+ diskPlugins: config.plugins,
2110
+ root,
2111
+ outputPath: config.output.path
2112
+ });
2113
+ return merged;
2027
2114
  }),
2028
2115
  mergeAdapter(config.adapter, patch?.adapter, extensions),
2029
2116
  mergeParsers({