@topogram/cli 0.3.39 → 0.3.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.39",
3
+ "version": "0.3.40",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -6170,7 +6170,7 @@ function buildTemplateCheckPayload(templateSpec) {
6170
6170
  configDir: projectConfigInfo.configDir
6171
6171
  }
6172
6172
  : null;
6173
- if (implementationInfo && implementationRequiresTrust(implementationInfo)) {
6173
+ if (implementationInfo && implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
6174
6174
  const trustStatus = getTemplateTrustStatus(implementationInfo, projectConfigInfo.config);
6175
6175
  const trustDiagnostics = trustStatus.issues.map((issue) => templateCheckDiagnostic({
6176
6176
  code: "template_trust_invalid",
@@ -7796,7 +7796,7 @@ try {
7796
7796
  configPath: projectConfigInfo.configPath,
7797
7797
  configDir: projectConfigInfo.configDir
7798
7798
  };
7799
- if (implementationRequiresTrust(implementationInfo)) {
7799
+ if (implementationRequiresTrust(implementationInfo, projectConfigInfo.config)) {
7800
7800
  const trustRecord = writeTemplateTrustRecord(projectConfigInfo.configDir, projectConfigInfo.config);
7801
7801
  console.log(`Wrote ${TEMPLATE_TRUST_FILE} for ${trustRecord.implementation.module}.`);
7802
7802
  if (trustRecord.template.id) {
@@ -331,6 +331,34 @@ function readTemplateManifest(templateRoot) {
331
331
  return validateTemplateManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
332
332
  }
333
333
 
334
+ /**
335
+ * @param {string} root
336
+ * @param {string} currentDir
337
+ * @param {string} label
338
+ * @param {string} templateId
339
+ * @returns {void}
340
+ */
341
+ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
342
+ const rootStat = fs.lstatSync(currentDir);
343
+ const relativeRoot = path.relative(root, currentDir).replace(/\\/g, "/") || label;
344
+ if (rootStat.isSymbolicLink()) {
345
+ throw new Error(`Template '${templateId}' contains unsupported symlink '${relativeRoot}'.`);
346
+ }
347
+ if (!rootStat.isDirectory()) {
348
+ return;
349
+ }
350
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
351
+ const entryPath = path.join(currentDir, entry.name);
352
+ const relativePath = path.relative(root, entryPath).replace(/\\/g, "/");
353
+ if (entry.isSymbolicLink()) {
354
+ throw new Error(`Template '${templateId}' contains unsupported symlink '${relativePath}'.`);
355
+ }
356
+ if (entry.isDirectory()) {
357
+ assertTemplateTreeHasNoSymlinks(root, entryPath, label, templateId);
358
+ }
359
+ }
360
+ }
361
+
334
362
  /**
335
363
  * @param {string} templateRoot
336
364
  * @returns {TemplateManifest}
@@ -339,19 +367,30 @@ function validateTemplateRoot(templateRoot) {
339
367
  const manifest = readTemplateManifest(templateRoot);
340
368
  const topogramRoot = path.join(templateRoot, "topogram");
341
369
  const projectConfigPath = path.join(templateRoot, "topogram.project.json");
370
+ if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
371
+ throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram'.`);
372
+ }
373
+ if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
374
+ throw new Error(`Template '${manifest.id}' contains unsupported symlink 'topogram.project.json'.`);
375
+ }
342
376
  if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
343
377
  throw new Error(`Template '${manifest.id}' is missing topogram/.`);
344
378
  }
345
379
  if (!fs.existsSync(projectConfigPath) || !fs.statSync(projectConfigPath).isFile()) {
346
380
  throw new Error(`Template '${manifest.id}' is missing topogram.project.json.`);
347
381
  }
382
+ assertTemplateTreeHasNoSymlinks(templateRoot, topogramRoot, "topogram", manifest.id);
348
383
  if (manifest.includesExecutableImplementation) {
349
384
  const implementationRoot = path.join(templateRoot, "implementation");
385
+ if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
386
+ throw new Error(`Template '${manifest.id}' contains unsupported symlink 'implementation'.`);
387
+ }
350
388
  if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
351
389
  throw new Error(
352
390
  `Template '${manifest.id}' declares executable implementation code but is missing implementation/.`
353
391
  );
354
392
  }
393
+ assertTemplateTreeHasNoSymlinks(templateRoot, implementationRoot, "implementation", manifest.id);
355
394
  } else {
356
395
  const implementationRoot = path.join(templateRoot, "implementation");
357
396
  if (fs.existsSync(implementationRoot) && fs.statSync(implementationRoot).isDirectory()) {
@@ -1129,6 +1168,9 @@ function collectFiles(root, currentDir, files) {
1129
1168
  continue;
1130
1169
  }
1131
1170
  const entryPath = path.join(currentDir, entry.name);
1171
+ if (entry.isSymbolicLink()) {
1172
+ throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'.`);
1173
+ }
1132
1174
  if (entry.isDirectory()) {
1133
1175
  collectFiles(root, entryPath, files);
1134
1176
  continue;
@@ -185,12 +185,16 @@ function collectImplementationFiles(implementationRoot, currentDir, files) {
185
185
  continue;
186
186
  }
187
187
  const entryPath = path.join(currentDir, entry.name);
188
+ const relativePath = normalizeRelativePath(path.relative(implementationRoot, entryPath));
189
+ if (entry.isSymbolicLink()) {
190
+ throw new Error(`Template implementation contains unsupported symlink '${relativePath}'.`);
191
+ }
188
192
  if (entry.isDirectory()) {
189
193
  collectImplementationFiles(implementationRoot, entryPath, files);
190
194
  continue;
191
195
  }
192
196
  if (entry.isFile()) {
193
- files.push(normalizeRelativePath(path.relative(implementationRoot, entryPath)));
197
+ files.push(relativePath);
194
198
  }
195
199
  }
196
200
  }
@@ -250,17 +254,38 @@ export function implementationTrustFingerprint(config) {
250
254
  };
251
255
  }
252
256
 
257
+ /**
258
+ * @param {{ config: Record<string, any>, configDir: string }} implementationInfo
259
+ * @param {Record<string, any>|null} [projectConfig]
260
+ * @returns {boolean}
261
+ */
262
+ export function implementationRequiresTrust(implementationInfo, projectConfig = null) {
263
+ const fingerprint = implementationTrustFingerprint(implementationInfo.config);
264
+ const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
265
+ const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
266
+ return isSameOrInside(implementationRoot, modulePath) || projectHasTemplateAttachment(projectConfig);
267
+ }
268
+
253
269
  /**
254
270
  * @param {{ config: Record<string, any>, configDir: string }} implementationInfo
255
271
  * @returns {boolean}
256
272
  */
257
- export function implementationRequiresTrust(implementationInfo) {
273
+ function implementationModuleIsUnderRoot(implementationInfo) {
258
274
  const fingerprint = implementationTrustFingerprint(implementationInfo.config);
259
275
  const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
260
276
  const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
261
277
  return isSameOrInside(implementationRoot, modulePath);
262
278
  }
263
279
 
280
+ /**
281
+ * @param {Record<string, any>|null} projectConfig
282
+ * @returns {boolean}
283
+ */
284
+ function projectHasTemplateAttachment(projectConfig) {
285
+ const template = projectConfig?.template || null;
286
+ return Boolean(template?.id || template?.sourceSpec || template?.requested);
287
+ }
288
+
264
289
  /**
265
290
  * @param {string} configDir
266
291
  * @returns {TemplateTrustRecord|null}
@@ -312,6 +337,14 @@ export function writeTemplateTrustRecord(configDir, projectConfig) {
312
337
  if (!implementationConfig) {
313
338
  throw new Error("Cannot trust template implementation because topogram.project.json has no implementation config.");
314
339
  }
340
+ const implementationInfo = {
341
+ config: implementationConfig,
342
+ configDir
343
+ };
344
+ if (!implementationModuleIsUnderRoot(implementationInfo)) {
345
+ const implementation = implementationTrustFingerprint(implementationConfig);
346
+ throw new Error(`Template implementation module '${implementation.module}' must be under implementation/.`);
347
+ }
315
348
  const implementation = implementationTrustFingerprint(implementationConfig);
316
349
  const record = buildTrustRecord(configDir, projectConfig, implementation);
317
350
  fs.writeFileSync(path.join(configDir, TEMPLATE_TRUST_FILE), `${JSON.stringify(record, null, 2)}\n`, "utf8");
@@ -393,7 +426,8 @@ function implementationReviewFile(configDir, relativePath, file) {
393
426
  * @returns {{ ok: boolean, requiresTrust: boolean, trustPath: string, trustRecord: TemplateTrustRecord|null, template: { id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null, sourceRoot: string|null, catalog?: Record<string, any>|null, includesExecutableImplementation: boolean|null }, implementation: { id: string|null, module: string|null, export: string|null }, content: { trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }, issues: string[] }}
394
427
  */
395
428
  export function getTemplateTrustStatus(implementationInfo, projectConfig = null) {
396
- if (!implementationRequiresTrust(implementationInfo)) {
429
+ const templateAttached = projectHasTemplateAttachment(projectConfig);
430
+ if (!implementationRequiresTrust(implementationInfo, projectConfig)) {
397
431
  return {
398
432
  ok: true,
399
433
  requiresTrust: false,
@@ -406,6 +440,7 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
406
440
  };
407
441
  }
408
442
  const fingerprint = implementationTrustFingerprint(implementationInfo.config);
443
+ const moduleInsideImplementation = implementationModuleIsUnderRoot(implementationInfo);
409
444
  const trustRecord = readTemplateTrustRecord(implementationInfo.configDir);
410
445
  const configLabel = implementationInfo.configPath || "topogram.project.json";
411
446
  const trustPath = path.join(implementationInfo.configDir, TEMPLATE_TRUST_FILE);
@@ -415,6 +450,12 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
415
450
  /** @type {{ trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }} */
416
451
  const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
417
452
 
453
+ if (templateAttached && !moduleInsideImplementation) {
454
+ issues.push(
455
+ `Template implementation module '${fingerprint.module}' must be under implementation/ for template-attached projects`
456
+ );
457
+ }
458
+
418
459
  if (!trustRecord) {
419
460
  issues.push(
420
461
  `Refusing to load executable implementation '${fingerprint.module}' from ${normalizeRoot(configLabel)} without ${TEMPLATE_TRUST_FILE}`
@@ -441,7 +482,10 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
441
482
  issues.push(`${TEMPLATE_TRUST_FILE} trusts template version '${trustRecord.template?.version}', but topogram.project.json declares '${projectTemplate.version}'`);
442
483
  }
443
484
 
444
- if (!trustRecord.content) {
485
+ if (!moduleInsideImplementation) {
486
+ // The module itself is outside the only supported trust root. Do not
487
+ // pretend the implementation/ content digest covers the executable code.
488
+ } else if (!trustRecord.content) {
445
489
  issues.push(`${TEMPLATE_TRUST_FILE} is missing implementation content hashes`);
446
490
  } else if (trustRecord.content.algorithm !== "sha256") {
447
491
  issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);