@topogram/cli 0.3.85 → 0.3.87

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.
@@ -83,6 +83,11 @@ export const DEFAULT_TOPOGRAM_CONFIG = {
83
83
  consumers: DEFAULT_RELEASE_CONSUMER_REPOS,
84
84
  workflows: DEFAULT_RELEASE_CONSUMER_WORKFLOWS,
85
85
  workflowJobs: DEFAULT_RELEASE_CONSUMER_WORKFLOW_JOBS
86
+ },
87
+ limits: {
88
+ remoteFetchMaxBytes: 5 * 1024 * 1024,
89
+ catalogFetchMaxBytes: null,
90
+ githubFetchMaxBytes: null
86
91
  }
87
92
  };
88
93
 
@@ -93,6 +98,7 @@ export const DEFAULT_CATALOG_SOURCE = `https://raw.githubusercontent.com/${DEFAU
93
98
  * @property {{ owner: string, repo: string }} github
94
99
  * @property {{ owner: string, repo: string, ref: string, path: string, source: string|null }} catalog
95
100
  * @property {{ consumers: string[], workflows: Record<string, string>, workflowJobs: Record<string, string[]> }} release
101
+ * @property {{ remoteFetchMaxBytes: number, catalogFetchMaxBytes: number|null, githubFetchMaxBytes: number|null }} limits
96
102
  */
97
103
 
98
104
  /**
@@ -154,6 +160,18 @@ function parseJsonEnv(value) {
154
160
  return JSON.parse(value);
155
161
  }
156
162
 
163
+ /**
164
+ * @param {string|null|undefined} value
165
+ * @returns {number|null}
166
+ */
167
+ function parsePositiveIntegerEnv(value) {
168
+ if (!value) {
169
+ return null;
170
+ }
171
+ const parsed = Number.parseInt(String(value), 10);
172
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
173
+ }
174
+
157
175
  /**
158
176
  * @param {Record<string, any>} fileConfig
159
177
  * @returns {Record<string, any>}
@@ -178,6 +196,11 @@ function envConfig(fileConfig = {}) {
178
196
  consumers: consumers || fileConfig.release?.consumers,
179
197
  workflows: workflows || fileConfig.release?.workflows,
180
198
  workflowJobs: workflowJobs || fileConfig.release?.workflowJobs
199
+ },
200
+ limits: {
201
+ remoteFetchMaxBytes: parsePositiveIntegerEnv(process.env.TOPOGRAM_REMOTE_FETCH_MAX_BYTES) || fileConfig.limits?.remoteFetchMaxBytes,
202
+ catalogFetchMaxBytes: parsePositiveIntegerEnv(process.env.TOPOGRAM_CATALOG_FETCH_MAX_BYTES) || fileConfig.limits?.catalogFetchMaxBytes,
203
+ githubFetchMaxBytes: parsePositiveIntegerEnv(process.env.TOPOGRAM_GITHUB_FETCH_MAX_BYTES) || fileConfig.limits?.githubFetchMaxBytes
181
204
  }
182
205
  };
183
206
  }
@@ -235,6 +258,16 @@ function normalizeStringListMap(value, fallback) {
235
258
  return output;
236
259
  }
237
260
 
261
+ /**
262
+ * @param {unknown} value
263
+ * @param {number|null} fallback
264
+ * @returns {number|null}
265
+ */
266
+ function normalizePositiveInteger(value, fallback) {
267
+ const parsed = Number.parseInt(String(value || ""), 10);
268
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
269
+ }
270
+
238
271
  /**
239
272
  * @param {string} cwd
240
273
  * @returns {TopogramRuntimeConfig}
@@ -258,6 +291,20 @@ export function topogramRuntimeConfig(cwd = process.cwd()) {
258
291
  consumers: normalizeStringList(overrides.release.consumers, DEFAULT_TOPOGRAM_CONFIG.release.consumers),
259
292
  workflows: normalizeStringMap(overrides.release.workflows, DEFAULT_TOPOGRAM_CONFIG.release.workflows),
260
293
  workflowJobs: normalizeStringListMap(overrides.release.workflowJobs, DEFAULT_TOPOGRAM_CONFIG.release.workflowJobs)
294
+ },
295
+ limits: {
296
+ remoteFetchMaxBytes: normalizePositiveInteger(
297
+ overrides.limits.remoteFetchMaxBytes,
298
+ DEFAULT_TOPOGRAM_CONFIG.limits.remoteFetchMaxBytes
299
+ ) || DEFAULT_TOPOGRAM_CONFIG.limits.remoteFetchMaxBytes,
300
+ catalogFetchMaxBytes: normalizePositiveInteger(
301
+ overrides.limits.catalogFetchMaxBytes,
302
+ DEFAULT_TOPOGRAM_CONFIG.limits.catalogFetchMaxBytes
303
+ ),
304
+ githubFetchMaxBytes: normalizePositiveInteger(
305
+ overrides.limits.githubFetchMaxBytes,
306
+ DEFAULT_TOPOGRAM_CONFIG.limits.githubFetchMaxBytes
307
+ )
261
308
  }
262
309
  };
263
310
  }
@@ -12,6 +12,27 @@ import { readJsonIfExists, readTextIfExists } from "../../shared.js";
12
12
  import { canonicalRelativePathForItem } from "./paths.js";
13
13
  import { applyProjectionAuthPatchToTopogram } from "./projection-patches.js";
14
14
 
15
+ /** @param {string} rootDir @param {string} relativePath @param {string} fieldName @returns {{ absolutePath: string, relativePath: string }} */
16
+ function resolveContainedTopoPath(rootDir, relativePath, fieldName) {
17
+ const rawPath = String(relativePath || "").replaceAll("\\", "/");
18
+ if (!rawPath.trim()) {
19
+ throw new Error(`Adoption plan ${fieldName} must be a non-empty relative path.`);
20
+ }
21
+ if (rawPath.includes("\0") || path.isAbsolute(rawPath) || /^[A-Za-z]:\//.test(rawPath)) {
22
+ throw new Error(`Adoption plan ${fieldName} must be relative to the topo workspace: ${relativePath}`);
23
+ }
24
+ const absoluteRoot = path.resolve(rootDir);
25
+ const absolutePath = path.resolve(absoluteRoot, rawPath);
26
+ const relativeToRoot = path.relative(absoluteRoot, absolutePath);
27
+ if (!relativeToRoot || relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
28
+ throw new Error(`Adoption plan ${fieldName} escapes the topo workspace: ${relativePath}`);
29
+ }
30
+ return {
31
+ absolutePath,
32
+ relativePath: relativeToRoot.replaceAll(path.sep, "/")
33
+ };
34
+ }
35
+
15
36
  /** @param {WorkspacePaths} paths @returns {any} */
16
37
  export function readAdoptionPlan(paths) {
17
38
  return readJsonIfExists(path.join(paths.topogramRoot, "candidates", "reconcile", "adoption-plan.json"));
@@ -55,11 +76,15 @@ export function buildCanonicalAdoptionOutputs(paths, candidateFiles, planItems,
55
76
  if (item.suggested_action === "skip_duplicate_shape") {
56
77
  continue;
57
78
  }
58
- const relativeCanonicalPath = item.canonical_rel_path || canonicalRelativePathForItem(item.kind, item.item);
59
- if (!relativeCanonicalPath) {
79
+ const rawRelativeCanonicalPath = item.canonical_rel_path || canonicalRelativePathForItem(item.kind, item.item);
80
+ if (!rawRelativeCanonicalPath) {
60
81
  continue;
61
82
  }
62
- const canonicalPath = path.join(paths.topogramRoot, relativeCanonicalPath);
83
+ const { absolutePath: canonicalPath, relativePath: relativeCanonicalPath } = resolveContainedTopoPath(
84
+ paths.topogramRoot,
85
+ rawRelativeCanonicalPath,
86
+ "canonical_rel_path"
87
+ );
63
88
  if (item.suggested_action === "apply_doc_link_patch") {
64
89
  const baseContents = files[relativeCanonicalPath] || (fs.existsSync(canonicalPath) ? fs.readFileSync(canonicalPath, "utf8") : null);
65
90
  if (!baseContents) {
@@ -113,7 +138,9 @@ export function buildCanonicalAdoptionOutputs(paths, candidateFiles, planItems,
113
138
  }
114
139
  const candidateContents =
115
140
  candidateFiles[item.source_path] ||
116
- (item.source_path ? readTextIfExists(path.join(paths.topogramRoot, item.source_path)) : null);
141
+ (item.source_path
142
+ ? readTextIfExists(resolveContainedTopoPath(paths.topogramRoot, item.source_path, "source_path").absolutePath)
143
+ : null);
117
144
  if (!candidateContents) {
118
145
  continue;
119
146
  }
@@ -79,15 +79,36 @@ export function listFilesRecursive(rootDir, predicate = () => true, options = {}
79
79
  return [];
80
80
  }
81
81
  const ignoredDirs = options.ignoredDirs || DEFAULT_IGNORED_DIRS;
82
+ let rootRealPath;
83
+ try {
84
+ rootRealPath = fs.realpathSync(rootDir);
85
+ } catch {
86
+ return [];
87
+ }
88
+ const visitedDirs = new Set([rootRealPath]);
82
89
  /** @type {any[]} */
83
90
  const files = [];
84
91
  const walk = (/** @type {any} */ currentDir) => {
85
92
  for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
86
93
  const childPath = path.join(currentDir, entry.name);
94
+ if (entry.isSymbolicLink()) {
95
+ continue;
96
+ }
87
97
  if (entry.isDirectory()) {
88
98
  if (ignoredDirs.has(entry.name)) {
89
99
  continue;
90
100
  }
101
+ let childRealPath;
102
+ try {
103
+ childRealPath = fs.realpathSync(childPath);
104
+ } catch {
105
+ continue;
106
+ }
107
+ const relativeToRoot = path.relative(rootRealPath, childRealPath);
108
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) || visitedDirs.has(childRealPath)) {
109
+ continue;
110
+ }
111
+ visitedDirs.add(childRealPath);
91
112
  walk(childPath);
92
113
  continue;
93
114
  }